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_registry;
10mod condition_parser;
11mod css_link;
12mod css_parser;
13mod diagnostic;
14mod error;
15mod handlebars_parser;
16mod html_parser;
17pub mod plugin;
18mod route_parser;
19mod suggest;
20
21pub use asset_filename::{
22    AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
23};
24pub use component_registry::{Component, ComponentRegistration, ComponentRegistry};
25pub use condition_parser::ConditionParser;
26pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
27pub use css_parser::CssParser;
28pub use diagnostic::{codes, Diagnostic, Severity};
29pub use error::{ParserError, Result};
30pub use handlebars_parser::HandlebarsParser;
31pub use webui_tokens::CssFallbackChain;
32
33use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
34use crate::plugin::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
35use std::collections::{HashMap, HashSet};
36use std::ops::Range;
37use webui_protocol::{
38    web_ui_fragment, web_ui_fragment::Fragment, ConditionExpr, FragmentList, WebUIFragment,
39    WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentRoute,
40};
41
42/// Maximum template size accepted by the parser.
43///
44/// Build-time parsing should never need to ingest arbitrarily large single
45/// templates. This cap prevents accidental or malicious OOM-scale inputs while
46/// staying far above normal component/page sizes.
47const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
48
49/// Maximum nested HTML element depth accepted by semantic parsing.
50///
51/// The scanner itself is iterative, but semantic fragment construction still
52/// enters child ranges. This limit keeps pathological nesting from exhausting
53/// the Rust call stack while preserving generous headroom for real templates.
54const MAX_TEMPLATE_DEPTH: usize = 512;
55
56/// Prefix for compiler-owned structural signals.
57///
58/// `}}}` cannot occur in a parsed double- or triple-brace expression: it closes
59/// the binding first. This keeps transport structure distinct from every
60/// authored state key without changing the protobuf schema.
61const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
62
63fn structural_signal(value: impl AsRef<str>) -> WebUIFragment {
64    WebUIFragment::signal(
65        format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
66        true,
67    )
68}
69
70/// How an element affects the HTML insertion mode that decides whether an
71/// unknown element (the generated `<webui-hydrate>` sentinel) is
72/// foster-parented away from its payload script.
73#[derive(Clone, Copy, PartialEq, Eq)]
74enum FosterContext {
75    /// Does not change the current insertion mode.
76    Transparent,
77    /// "in table"/"in table body"/"in row"/"in column group"/"in select":
78    /// unknown elements are foster-parented out of this subtree.
79    Hostile,
80    /// `<td>`/`<th>`/`<caption>` return to "in body" rules, so a boundary
81    /// inside them is safe even though an ancestor is hostile.
82    Barrier,
83}
84
85/// Classify `name` for foster-parenting purposes.
86///
87/// Dispatching on length first means the common non-table element (`div`,
88/// `span`, `p`, `section`, …) costs one integer compare and a fallthrough,
89/// keeping this off the parser's measured hot path.
90#[inline]
91fn foster_context_of(name: &str) -> FosterContext {
92    match name.len() {
93        2 => {
94            if name.eq_ignore_ascii_case("tr") {
95                FosterContext::Hostile
96            } else if name.eq_ignore_ascii_case("td") || name.eq_ignore_ascii_case("th") {
97                FosterContext::Barrier
98            } else {
99                FosterContext::Transparent
100            }
101        }
102        5 => {
103            if name.eq_ignore_ascii_case("table")
104                || name.eq_ignore_ascii_case("tbody")
105                || name.eq_ignore_ascii_case("thead")
106                || name.eq_ignore_ascii_case("tfoot")
107            {
108                FosterContext::Hostile
109            } else {
110                FosterContext::Transparent
111            }
112        }
113        6 => {
114            if name.eq_ignore_ascii_case("select") {
115                FosterContext::Hostile
116            } else {
117                FosterContext::Transparent
118            }
119        }
120        7 => {
121            if name.eq_ignore_ascii_case("caption") {
122                FosterContext::Barrier
123            } else {
124                FosterContext::Transparent
125            }
126        }
127        8 => {
128            if name.eq_ignore_ascii_case("optgroup") || name.eq_ignore_ascii_case("colgroup") {
129                FosterContext::Hostile
130            } else {
131                FosterContext::Transparent
132            }
133        }
134        _ => FosterContext::Transparent,
135    }
136}
137
138/// Return the browser parsing context that prevents a nested boundary sentinel
139/// from becoming an active custom element.
140#[inline]
141fn boundary_parent_scope(name: &str) -> Option<&'static str> {
142    match name.len() {
143        3 if name.eq_ignore_ascii_case("xmp") => Some("<xmp> raw-text content"),
144        5 if name.eq_ignore_ascii_case("title") => Some("<title> text content"),
145        6 if name.eq_ignore_ascii_case("iframe") => Some("<iframe> raw-text content"),
146        6 if name.eq_ignore_ascii_case("script") => Some("<script> raw-text content"),
147        7 if name.eq_ignore_ascii_case("noembed") => Some("<noembed> raw-text content"),
148        8 if name.eq_ignore_ascii_case("template") => Some("<template> inert content"),
149        8 if name.eq_ignore_ascii_case("textarea") => Some("<textarea> text content"),
150        8 if name.eq_ignore_ascii_case("noframes") => Some("<noframes> raw-text content"),
151        8 if name.eq_ignore_ascii_case("noscript") => Some("<noscript> inert content"),
152        9 if name.eq_ignore_ascii_case("plaintext") => Some("<plaintext> text content"),
153        _ => None,
154    }
155}
156
157/// Strategy for how component CSS is delivered in rendered output.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
159#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
160pub enum CssStrategy {
161    /// Emit `<link rel="stylesheet" href="component.css">` tags (default).
162    #[default]
163    Link,
164    /// Embed CSS content in `<style>` tags within the component.
165    Style,
166    /// Register each component's CSS module as a `<script type="importmap">`
167    /// data-URI entry. The client runtime applies the registered sheet via
168    /// the document's adopted stylesheets.
169    Module,
170}
171
172impl std::fmt::Display for CssStrategy {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            CssStrategy::Link => write!(f, "link"),
176            CssStrategy::Style => write!(f, "style"),
177            CssStrategy::Module => write!(f, "module"),
178        }
179    }
180}
181
182impl std::str::FromStr for CssStrategy {
183    type Err = String;
184
185    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
186        match s {
187            "link" => Ok(CssStrategy::Link),
188            "style" => Ok(CssStrategy::Style),
189            "module" => Ok(CssStrategy::Module),
190            other => Err(format!(
191                "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
192            )),
193        }
194    }
195}
196
197/// Strategy for how component DOM is structured.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
199#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
200pub enum DomStrategy {
201    /// Use shadow DOM with declarative shadow roots for SSR (default).
202    #[default]
203    Shadow,
204    /// Use light DOM — component content is rendered as direct children.
205    Light,
206}
207
208impl std::fmt::Display for DomStrategy {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        match self {
211            DomStrategy::Shadow => write!(f, "shadow"),
212            DomStrategy::Light => write!(f, "light"),
213        }
214    }
215}
216
217impl std::str::FromStr for DomStrategy {
218    type Err = String;
219
220    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
221        match s {
222            "shadow" => Ok(DomStrategy::Shadow),
223            "light" => Ok(DomStrategy::Light),
224            other => Err(format!(
225                "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
226            )),
227        }
228    }
229}
230
231/// Strategy for preserving legal comments in generated output.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
233#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
234pub enum LegalComments {
235    /// Strip every HTML and CSS comment.
236    None,
237    /// Preserve legal CSS comments inline and strip all other comments.
238    #[default]
239    Inline,
240}
241
242impl std::fmt::Display for LegalComments {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            LegalComments::None => write!(f, "none"),
246            LegalComments::Inline => write!(f, "inline"),
247        }
248    }
249}
250
251impl std::str::FromStr for LegalComments {
252    type Err = String;
253
254    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
255        match s {
256            "none" => Ok(LegalComments::None),
257            "inline" => Ok(LegalComments::Inline),
258            other => Err(format!(
259                "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
260            )),
261        }
262    }
263}
264
265/// Build/output options that affect parser-generated component templates.
266#[derive(Debug, Clone, Default)]
267pub struct ParserOptions {
268    /// Strategy for how component CSS is delivered.
269    pub css_strategy: CssStrategy,
270    /// Strategy for how component DOM is rendered.
271    pub dom_strategy: DomStrategy,
272    /// Link-mode CSS filename/href options.
273    pub css_link_options: CssLinkOptions,
274    /// Legal comment preservation strategy.
275    pub legal_comments: LegalComments,
276}
277
278impl ParserOptions {
279    /// Create parser output options.
280    ///
281    /// # Errors
282    ///
283    /// Returns [`ParserError::Css`] when Link-mode CSS link options are invalid.
284    pub fn try_new(
285        css_strategy: CssStrategy,
286        dom_strategy: DomStrategy,
287        css_file_name_template: &str,
288        css_public_base: Option<&str>,
289        legal_comments: LegalComments,
290    ) -> Result<Self> {
291        let css_link_options = if css_strategy == CssStrategy::Link {
292            CssLinkOptions::try_new(
293                css_file_name_template.to_string(),
294                css_public_base.map(std::string::ToString::to_string),
295            )?
296        } else {
297            CssLinkOptions::default()
298        };
299
300        Ok(Self {
301            css_strategy,
302            dom_strategy,
303            css_link_options,
304            legal_comments,
305        })
306    }
307}
308
309impl From<CssStrategy> for ParserOptions {
310    fn from(css_strategy: CssStrategy) -> Self {
311        Self {
312            css_strategy,
313            ..Self::default()
314        }
315    }
316}
317
318impl From<DomStrategy> for ParserOptions {
319    fn from(dom_strategy: DomStrategy) -> Self {
320        Self {
321            dom_strategy,
322            ..Self::default()
323        }
324    }
325}
326
327impl From<(CssStrategy, DomStrategy)> for ParserOptions {
328    fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
329        Self {
330            css_strategy,
331            dom_strategy,
332            ..Self::default()
333        }
334    }
335}
336
337/// Framework plugin to load for build-time and render-time processing.
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
340pub enum Plugin {
341    /// Deprecated compatibility alias for the FAST 2 hydration plugin.
342    #[cfg_attr(feature = "cli", value(name = "fast"))]
343    Fast,
344    /// Deprecated FAST 2 hydration plugin with legacy marker output.
345    #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
346    FastV2,
347    /// FAST 3 hydration plugin with compact marker output.
348    #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
349    FastV3,
350    /// WebUI plugin — full component model with shadow DOM support.
351    #[cfg_attr(feature = "cli", value(name = "webui"))]
352    WebUI,
353}
354
355impl std::fmt::Display for Plugin {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        match self {
358            Plugin::Fast => write!(f, "fast"),
359            Plugin::FastV2 => write!(f, "fast-v2"),
360            Plugin::FastV3 => write!(f, "fast-v3"),
361            Plugin::WebUI => write!(f, "webui"),
362        }
363    }
364}
365
366impl std::str::FromStr for Plugin {
367    type Err = String;
368
369    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
370        match s {
371            "fast" => Ok(Plugin::Fast),
372            "fast-v2" => Ok(Plugin::FastV2),
373            "fast-v3" => Ok(Plugin::FastV3),
374            "webui" => Ok(Plugin::WebUI),
375            other => Err(format!(
376                "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
377            )),
378        }
379    }
380}
381
382/// Counter for generating unique fragment IDs.
383struct FragmentIdCounter {
384    /// Map of counter types to their current values.
385    counters: HashMap<String, usize>,
386}
387
388impl FragmentIdCounter {
389    /// Create a new fragment ID counter.
390    fn new() -> Self {
391        Self {
392            counters: HashMap::new(),
393        }
394    }
395
396    /// Generate a unique fragment ID.
397    fn next_id(&mut self, prefix: &str) -> String {
398        let count = self.counters.entry(prefix.to_string()).or_insert(0);
399        *count += 1;
400        format!("{}-{}", prefix, count)
401    }
402}
403
404struct ParseContext {
405    fragments: Vec<WebUIFragment>,
406    raw_buffer: String,
407}
408
409#[derive(Default)]
410struct FragmentCssTokens {
411    definitions: Vec<String>,
412    fallback_chains: Vec<CssFallbackChain>,
413}
414
415/// CSS token analysis produced from the parsed template/component graph.
416#[derive(Debug, Clone, PartialEq, Eq)]
417pub struct CssTokenAnalysis {
418    /// Sorted, deduplicated token candidates stored in the protocol.
419    pub protocol_tokens: Vec<String>,
420    /// Fallback chains that still need theme/literal coverage after local and
421    /// ancestor custom-property definitions are considered.
422    pub fallback_chains: Vec<CssFallbackChain>,
423    /// Source location of each unresolved token's first `var()` usage, keyed by
424    /// token name. Used to point theme-validation diagnostics at the offending
425    /// CSS. Build-time only; never serialized.
426    pub(crate) token_sites: HashMap<String, TokenSite>,
427}
428
429/// Where an unresolved CSS token is first referenced, for diagnostics.
430///
431/// `owner` is a file-like label (`my-card.css`, or the entry file for inline
432/// `<style>`). `position`/`snippet` are populated for component CSS where the
433/// source is a standalone file; inline styles record the owner only.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub(crate) struct TokenSite {
436    owner: String,
437    position: Option<(usize, usize)>,
438    snippet: Option<String>,
439}
440
441enum ParseOp<'a> {
442    Parse {
443        range: Range<usize>,
444        depth: usize,
445    },
446    EmitClose(&'a str),
447    EndHead(&'a str),
448    EndBody(&'a str),
449    /// Close a `<boundary>` opened with response-local sequence
450    /// number `seq`: emits the `boundary_end:<seq>` raw signal and clears
451    /// [`HtmlParser::in_boundary`].
452    EndBoundary(u32),
453    /// Restore [`HtmlParser::foster_context_depth`] to the saved value when
454    /// leaving an element that changed it.
455    ///
456    /// Save/restore of the single overwritten value keeps ancestor tracking
457    /// allocation-free: no ancestor stack is retained.
458    RestoreFosterDepth(u32),
459    /// Restore the enclosing component/raw/inert context after its child range
460    /// has been parsed.
461    RestoreBoundaryParentScope(Option<&'static str>),
462    CompleteFor {
463        parent: ParseContext,
464        item: String,
465        collection: String,
466        fragment_id: String,
467        keep_empty: bool,
468    },
469    CompleteIf {
470        parent: ParseContext,
471        condition: ConditionExpr,
472        fragment_id: String,
473    },
474}
475
476enum TokenGraphOp<'a> {
477    EnterFragment(&'a str),
478    EnterComponent(&'a str),
479    EnterRoute(&'a WebUiFragmentRoute),
480    ExitDefinitions(&'a [String]),
481}
482
483impl Default for HtmlParser {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489/// Parser for WebUI directives.
490pub struct HtmlParser {
491    /// CSS parser.
492    css_parser: CssParser,
493
494    /// Fragment ID counter.
495    id_counter: FragmentIdCounter,
496
497    /// Condition parser for parsing conditions in directives.
498    condition_parser: ConditionParser,
499
500    /// Handlebars parser for parsing handlebars expressions.
501    handlebars_parser: HandlebarsParser,
502
503    /// Component registry for WebUI components.
504    component_registry: ComponentRegistry,
505
506    /// Map of fragment IDs to their fragments
507    fragment_records: WebUIFragmentRecords,
508
509    /// Buffer for accumulating raw content
510    raw_buffer: String,
511
512    /// Parser output options for generated component templates.
513    options: ParserOptions,
514
515    /// Optional parser plugin for framework-specific behavior.
516    plugin: Option<Box<dyn ParserPlugin>>,
517
518    /// Top-level fragments parsed by callers. Token graph traversal starts
519    /// from these roots after parsing completes.
520    token_roots: Vec<String>,
521
522    /// CSS custom property definitions and fallback chains from inline
523    /// `<style>` tags, keyed by owning fragment id.
524    fragment_css_tokens: HashMap<String, FragmentCssTokens>,
525
526    /// Fragment IDs currently being parsed, used to reject recursive component
527    /// references before they can recurse through template parsing.
528    in_progress_fragments: HashSet<String>,
529
530    /// The fragment ID (entry file or component tag) currently being parsed.
531    /// Used to name the owning template in authoring [`Diagnostic`]s.
532    current_fragment_id: String,
533
534    /// Next `<boundary>` response-local sequence number
535    /// (`boundary_start:<seq>` / `boundary_end:<seq>`). Reset to `0` at the
536    /// start of each top-level [`HtmlParser::parse`] call.
537    boundary_sequence: u32,
538
539    /// `<boundary name>` values already used in the current top-level
540    /// parse, so a duplicate name can be rejected.
541    boundary_names: HashSet<String>,
542
543    /// `<boundary name>` values in declaration order for the current entry.
544    /// The build stores this table in `protocol.bin` for one cold host lookup.
545    boundary_names_in_order: Vec<String>,
546
547    /// `true` while parsing is inside an open `<boundary>`, used to
548    /// reject a nested boundary.
549    in_boundary: bool,
550
551    /// Number of currently open native `<body>` elements. A depth rather than
552    /// a boolean preserves the parent state while recursively parsing a
553    /// component template.
554    body_depth: usize,
555
556    /// Stack of open `<if>`/`<for>` scope descriptions, used to reject a
557    /// `<boundary>` that would cut through a conditionally-rendered or
558    /// repeated scope instead of independently wrapping it.
559    structural_scopes: Vec<&'static str>,
560
561    /// Number of enclosing elements whose HTML insertion mode would
562    /// foster-parent an unknown element such as the generated
563    /// `<webui-hydrate>` sentinel.
564    ///
565    /// Non-zero means a `<boundary>` here would be split from its
566    /// payload script by the browser's parser, so it must be rejected at build
567    /// time. `<td>`/`<th>`/`<caption>` reset it to `0` because those switch
568    /// back to "in body" insertion rules.
569    foster_context_depth: u32,
570
571    /// Enclosing component host content or native raw/inert HTML context.
572    ///
573    /// A generated sentinel in any of these contexts is either text or lives
574    /// in an inert fragment, so it cannot commit a streaming boundary.
575    boundary_parent_scope: Option<&'static str>,
576
577    /// `src` of every authored `<script type="module">` outside any
578    /// `<boundary>`, in document order, from the current top-level parse.
579    ///
580    /// These are the page's critical module entries. The build resolves each
581    /// against the projection manifest to emit `<link rel="modulepreload">`
582    /// for the shared chunks they statically import, which the browser's
583    /// preload scanner cannot discover on its own.
584    ///
585    /// Scripts *inside* a boundary are deliberately excluded: an island loader
586    /// is meant to be requested only when the parser reaches its chunk, so
587    /// preloading it would undo the deferral the author asked for.
588    module_entry_srcs: Vec<String>,
589}
590
591struct BuiltComponentTemplate {
592    ssr: String,
593    artifact: Option<String>,
594}
595
596impl BuiltComponentTemplate {
597    fn artifact(&self) -> &str {
598        self.artifact.as_deref().unwrap_or(&self.ssr)
599    }
600}
601
602fn add_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
603    for definition in definitions {
604        let count = available_counts.entry(definition.clone()).or_insert(0);
605        *count += 1;
606    }
607}
608
609fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<String, usize>) {
610    for definition in definitions {
611        if let Some(count) = available_counts.get_mut(definition) {
612            if *count == 1 {
613                available_counts.remove(definition);
614            } else {
615                *count -= 1;
616            }
617        }
618    }
619}
620
621/// Accumulators for the token-graph walk: the unresolved fallback chains and
622/// the per-token source locations, grouped so walk helpers stay within the
623/// argument-count budget.
624#[derive(Default)]
625struct UnresolvedTokens {
626    chains: Vec<CssFallbackChain>,
627    sites: HashMap<String, TokenSite>,
628}
629
630fn record_unresolved_requirements(
631    source: &[CssFallbackChain],
632    available_counts: &HashMap<String, usize>,
633    owner: &str,
634    css_source: Option<&str>,
635    out: &mut UnresolvedTokens,
636) {
637    for requirement in source {
638        let tokens: Vec<String> = requirement
639            .tokens
640            .iter()
641            .filter(|token| !available_counts.contains_key(*token))
642            .cloned()
643            .collect();
644        if tokens.is_empty() {
645            continue;
646        }
647        for token in &tokens {
648            if !out.sites.contains_key(token) {
649                out.sites
650                    .insert(token.clone(), token_site(owner, css_source, token));
651            }
652        }
653        out.chains.push(CssFallbackChain {
654            tokens,
655            has_literal_fallback: requirement.has_literal_fallback,
656        });
657    }
658}
659
660/// Build the [`TokenSite`] for `token`. When `css_source` is available (a
661/// component's standalone CSS), the token's `var()` usage is located for a
662/// precise `line:column` and snippet; otherwise only the `owner` is recorded.
663fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
664    let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
665        Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
666        None => (None, None),
667    };
668    TokenSite {
669        owner: owner.to_string(),
670        position,
671        snippet,
672    }
673}
674
675/// Locate the first `var(--token)` **usage** (not a `--token:` definition) in
676/// `css`, returning its 1-based `(line, column)` and a one-line snippet.
677///
678/// Iterative byte scan — no regex, no recursion. Cold-ish (build time, only for
679/// unresolved tokens).
680fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
681    let bytes = css.as_bytes();
682    let name = token.as_bytes();
683    let mut index = 0;
684    while index + 2 + name.len() <= bytes.len() {
685        let is_prefixed = bytes[index] == b'-'
686            && bytes[index + 1] == b'-'
687            && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
688        if is_prefixed {
689            let after = index + 2 + name.len();
690            let is_exact_name = bytes
691                .get(after)
692                .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
693            if is_exact_name {
694                let mut cursor = after;
695                while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
696                    cursor += 1;
697                }
698                // `--token:` is a definition, not the usage we want to point at.
699                if bytes.get(cursor) != Some(&b':') {
700                    let (line, column) = diagnostic::line_column(css, index);
701                    return Some((line, column, css_line_snippet(css, index)));
702                }
703            }
704        }
705        index += 1;
706    }
707    None
708}
709
710/// Extract the trimmed line containing byte `offset`, capped so a minified
711/// stylesheet does not produce a wall-of-text snippet.
712fn css_line_snippet(css: &str, offset: usize) -> String {
713    const MAX_SNIPPET: usize = 80;
714    let start = css[..offset].rfind('\n').map_or(0, |n| n + 1);
715    let end = css[offset..].find('\n').map_or(css.len(), |n| offset + n);
716    let line = css[start..end].trim();
717    if line.chars().count() > MAX_SNIPPET {
718        let truncated: String = line.chars().take(MAX_SNIPPET).collect();
719        format!("{truncated}…")
720    } else {
721        line.to_string()
722    }
723}
724
725/// File-like owner label for a component's standalone CSS
726/// (`my-card` → `my-card.css`), used as the `--> owner:line:column` prefix.
727fn css_owner_label(tag_name: &str) -> String {
728    let mut label = String::with_capacity(tag_name.len() + 4);
729    label.push_str(tag_name);
730    label.push_str(".css");
731    label
732}
733
734impl CssTokenAnalysis {
735    /// Validate the required CSS tokens against a loaded design-token theme.
736    ///
737    /// A token is *required* in every theme only when it appears in at least one
738    /// unresolved `var()` chain with **no** literal CSS fallback. A chain such as
739    /// `var(--x, 16px)` provides its own value, so `--x` stays in
740    /// [`protocol_tokens`](Self::protocol_tokens) for runtime resolution (the
741    /// theme value still wins when present) but does not fail the build when the
742    /// theme omits it. The chain/theme policy lives in
743    /// [`webui_tokens::validate_chain_tokens`]; this only adapts its
744    /// [`webui_tokens::TokenError`] into a structured [`Diagnostic`].
745    ///
746    /// # Errors
747    ///
748    /// Returns [`ParserError::Template`] with a stable diagnostic code when a
749    /// required token is missing from a theme. Theme token values are trusted and
750    /// their transitive references are left to browser CSS semantics.
751    pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
752        webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
753            .map_err(|source| self.theme_token_error(source, theme))
754    }
755
756    /// CSS tokens used **only** with a literal `var()` fallback and defined in no
757    /// theme — likely typos that callers may surface as non-fatal advisories.
758    ///
759    /// Thin wrapper over [`webui_tokens::unthemed_literal_fallback_tokens`].
760    #[must_use]
761    pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
762        webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
763    }
764
765    /// Structured, color-free advisories for CSS tokens used only with a
766    /// literal `var()` fallback and defined in no theme — almost always typos.
767    ///
768    /// Returned as warning-severity [`Diagnostic`]s so hosts render them with
769    /// the same location/snippet/`help:` layout as errors (the CLI colorizes;
770    /// other hosts use the plain [`Diagnostic::body`]). Each carries the token's
771    /// source location and a `did you mean --…?` suggestion.
772    #[must_use]
773    pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
774        let mut warnings = Vec::new();
775        for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
776            let help = match closest_theme_token(&token, theme.themes.values()) {
777                Some(suggestion) => {
778                    format!("did you mean --{suggestion}? otherwise the literal fallback is used")
779                }
780                None => format!(
781                    "define --{token} in the theme, or keep its literal fallback if intentional"
782                ),
783            };
784            let diag = self.locate_diagnostic(
785                Diagnostic::warning(format!("unthemed CSS token --{token}"))
786                    .code(codes::UNTHEMED_TOKEN)
787                    .help(help),
788                &token,
789            );
790            warnings.push(diag);
791        }
792        warnings
793    }
794
795    /// Adapt a [`webui_tokens::TokenError`] into a structured [`Diagnostic`],
796    /// enriching it with the token's source location and a `did you mean …?`
797    /// suggestion drawn from the theme's own tokens.
798    #[cold]
799    #[inline(never)]
800    fn theme_token_error(
801        &self,
802        source: webui_tokens::TokenError,
803        theme: &webui_tokens::TokenFile,
804    ) -> ParserError {
805        match source {
806            webui_tokens::TokenError::MissingToken {
807                theme: theme_name,
808                token,
809            } => {
810                // The error title + snippet already say the token is missing
811                // from the theme, so the help only adds what isn't obvious: the
812                // likely-typo suggestion and the local-definition escape hatch.
813                let help =
814                    match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
815                        Some(suggestion) => {
816                            format!("did you mean --{suggestion}? otherwise define it locally")
817                        }
818                        None => {
819                            "define it locally if it should not come from the theme".to_string()
820                        }
821                    };
822                self.locate_diagnostic(
823                    Diagnostic::error("missing theme token")
824                        .code(codes::MISSING_THEME_TOKEN)
825                        .help(help),
826                    &token,
827                )
828                .into()
829            }
830            other => ParserError::Generic(format!("Theme token validation failed: {other}")),
831        }
832    }
833
834    /// Attach `token`'s recorded source location (owner, `line:column`, snippet)
835    /// to `diag`. Falls back to a `--token` snippet when no location is known.
836    fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
837        let Some(site) = self.token_sites.get(token) else {
838            return diag.snippet(format!("--{token}"));
839        };
840        let mut diag = diag.component(site.owner.clone());
841        if let Some((line, column)) = site.position {
842            diag = diag.position(line, column);
843        }
844        match &site.snippet {
845            Some(snippet) => diag.snippet(snippet.clone()),
846            None => diag.snippet(format!("--{token}")),
847        }
848    }
849}
850
851/// Closest theme token to `target` by Levenshtein distance across the given
852/// theme token maps, considering each theme's keys. Sorted for determinism.
853#[cold]
854#[inline(never)]
855fn closest_theme_token<'a>(
856    target: &str,
857    themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
858) -> Option<String> {
859    let mut names: Vec<&str> = themes
860        .flat_map(|theme| theme.keys().map(String::as_str))
861        .collect();
862    names.sort_unstable();
863    names.dedup();
864    crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
865}
866
867impl HtmlParser {
868    /// Create a new directive parser with default parser options.
869    #[must_use]
870    pub fn new() -> Self {
871        Self::with_options(ParserOptions::default())
872    }
873
874    /// Create a new directive parser with explicit parser options.
875    #[must_use]
876    pub fn with_options(options: impl Into<ParserOptions>) -> Self {
877        let options = options.into();
878
879        Self {
880            component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
881            css_parser: CssParser::new(),
882            id_counter: FragmentIdCounter::new(),
883            condition_parser: ConditionParser::new(),
884            handlebars_parser: HandlebarsParser::new(),
885            raw_buffer: String::new(),
886            fragment_records: WebUIFragmentRecords::new(),
887            options,
888            plugin: None,
889            token_roots: Vec::new(),
890            fragment_css_tokens: HashMap::new(),
891            in_progress_fragments: HashSet::new(),
892            current_fragment_id: String::new(),
893            boundary_sequence: 0,
894            boundary_names: HashSet::new(),
895            boundary_names_in_order: Vec::new(),
896            in_boundary: false,
897            body_depth: 0,
898            structural_scopes: Vec::new(),
899            foster_context_depth: 0,
900            boundary_parent_scope: None,
901            module_entry_srcs: Vec::new(),
902        }
903    }
904
905    /// Create a new parser with a plugin and default parser options.
906    #[must_use]
907    pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
908        Self::with_plugin_options(plugin, ParserOptions::default())
909    }
910
911    /// Create a new parser with a plugin and explicit parser options.
912    #[must_use]
913    pub fn with_plugin_options(
914        plugin: Box<dyn ParserPlugin>,
915        options: impl Into<ParserOptions>,
916    ) -> Self {
917        let mut p = Self::with_options(options);
918        p.plugin = Some(plugin);
919        p.configure_plugin();
920        p
921    }
922
923    fn configure_plugin(&mut self) {
924        if let Some(ref mut plugin) = self.plugin {
925            plugin.configure(&self.options);
926        }
927    }
928
929    /// Get a mutable reference to the component registry.
930    pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
931        &mut self.component_registry
932    }
933
934    /// Get a shared reference to the component registry.
935    #[must_use]
936    pub fn component_registry(&self) -> &ComponentRegistry {
937        &self.component_registry
938    }
939
940    pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
941        std::mem::take(&mut self.fragment_records)
942    }
943
944    /// Check if a fragment ID has been parsed (exists in the fragment records).
945    pub fn has_fragment(&self, fragment_id: &str) -> bool {
946        self.fragment_records.contains_key(fragment_id)
947    }
948
949    /// Number of `<boundary>` streaming checkpoints compiled by the most
950    /// recent top-level [`HtmlParser::parse`] call.
951    ///
952    /// Returns `0` for an ordinary, non-streaming entry. Callers use this to
953    /// decide whether streaming-specific build advisories apply.
954    #[must_use]
955    pub fn boundary_count(&self) -> usize {
956        self.boundary_sequence as usize
957    }
958
959    /// Free-form `<boundary name>` values in declaration order from the most
960    /// recent top-level [`HtmlParser::parse`] call.
961    #[must_use]
962    pub fn boundary_names(&self) -> &[String] {
963        &self.boundary_names_in_order
964    }
965
966    /// `src` of every authored `<script type="module">` outside any
967    /// `<boundary>`, in document order, from the most recent top-level
968    /// [`HtmlParser::parse`] call.
969    ///
970    /// These are the page's critical module entries — the ones whose shared
971    /// chunks belong in `<link rel="modulepreload">`. Island loaders placed
972    /// inside a boundary are excluded, because deferring them is the point.
973    ///
974    /// A `src` containing `{{` is skipped: it is a binding resolved per
975    /// request, so no build-time artifact can be matched to it.
976    #[must_use]
977    pub fn module_entry_srcs(&self) -> &[String] {
978        &self.module_entry_srcs
979    }
980
981    /// Take any post-parse artifacts captured by the parser plugin.
982    ///
983    /// # Errors
984    ///
985    /// Returns [`ParserError::Template`] if a tracked component contains an
986    /// invalid `@event` handler or a non-braced `w-ref` binding.
987    pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
988        self.plugin
989            .take()
990            .map_or(Ok(ParserPluginArtifacts::None), |plugin| {
991                plugin.into_artifacts()
992            })
993    }
994
995    /// Take the accumulated CSS tokens as a sorted, deduplicated `Vec`.
996    ///
997    /// Convenience wrapper around [`HtmlParser::token_analysis`].
998    #[must_use]
999    pub fn take_tokens(&mut self) -> Vec<String> {
1000        self.token_analysis().protocol_tokens
1001    }
1002
1003    /// Analyze CSS token requirements from the parsed fragment/component graph.
1004    ///
1005    /// Each token candidate in a `var()` fallback chain is removed when that
1006    /// token is defined by CSS in the current fragment or an ancestor
1007    /// component/root. The returned protocol token list is sorted and
1008    /// deduplicated from the remaining unresolved fallback-chain candidates.
1009    #[must_use]
1010    pub fn token_analysis(&self) -> CssTokenAnalysis {
1011        let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
1012        let mut protocol_token_set = HashSet::new();
1013        for chain in &fallback_chains {
1014            for token in &chain.tokens {
1015                protocol_token_set.insert(token.clone());
1016            }
1017        }
1018        let mut protocol_tokens: Vec<String> = protocol_token_set.into_iter().collect();
1019        protocol_tokens.sort();
1020        CssTokenAnalysis {
1021            protocol_tokens,
1022            fallback_chains,
1023            token_sites,
1024        }
1025    }
1026
1027    fn collect_unresolved_fallback_chains(
1028        &self,
1029    ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
1030        let mut out = UnresolvedTokens::default();
1031        let mut available_counts: HashMap<String, usize> = HashMap::new();
1032        let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
1033        for root in self.token_roots.iter().rev() {
1034            ops.push(TokenGraphOp::EnterFragment(root.as_str()));
1035        }
1036
1037        while let Some(op) = ops.pop() {
1038            match op {
1039                TokenGraphOp::EnterFragment(fragment_id) => {
1040                    self.enter_token_fragment(
1041                        fragment_id,
1042                        &mut available_counts,
1043                        &mut out,
1044                        &mut ops,
1045                    );
1046                }
1047                TokenGraphOp::EnterComponent(tag_name) => {
1048                    self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
1049                }
1050                TokenGraphOp::EnterRoute(route) => {
1051                    self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
1052                }
1053                TokenGraphOp::ExitDefinitions(definitions) => {
1054                    remove_token_definitions(definitions, &mut available_counts);
1055                }
1056            }
1057        }
1058
1059        (out.chains, out.sites)
1060    }
1061
1062    fn enter_token_fragment<'a>(
1063        &'a self,
1064        fragment_id: &'a str,
1065        available_counts: &mut HashMap<String, usize>,
1066        out: &mut UnresolvedTokens,
1067        ops: &mut Vec<TokenGraphOp<'a>>,
1068    ) {
1069        if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
1070            add_token_definitions(&css.definitions, available_counts);
1071            // Inline `<style>` tokens record their owning fragment (the entry
1072            // file). A fragment may carry several `<style>` blocks, so an
1073            // offset into one body is ambiguous — record the owner only.
1074            record_unresolved_requirements(
1075                &css.fallback_chains,
1076                available_counts,
1077                fragment_id,
1078                None,
1079                out,
1080            );
1081            ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
1082        }
1083
1084        let Some(fragments) = self.fragment_records.get(fragment_id) else {
1085            return;
1086        };
1087        for fragment in fragments.fragments.iter().rev() {
1088            match fragment.fragment.as_ref() {
1089                Some(web_ui_fragment::Fragment::Component(component)) => {
1090                    ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
1091                }
1092                Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
1093                    ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
1094                }
1095                Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
1096                    ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
1097                }
1098                Some(web_ui_fragment::Fragment::Route(route)) => {
1099                    ops.push(TokenGraphOp::EnterRoute(route));
1100                }
1101                _ => {}
1102            }
1103        }
1104    }
1105
1106    fn enter_token_component<'a>(
1107        &'a self,
1108        tag_name: &'a str,
1109        available_counts: &mut HashMap<String, usize>,
1110        out: &mut UnresolvedTokens,
1111        ops: &mut Vec<TokenGraphOp<'a>>,
1112    ) {
1113        let Some(component) = self.component_registry.get(tag_name) else {
1114            return;
1115        };
1116        add_token_definitions(&component.css_definitions, available_counts);
1117        record_unresolved_requirements(
1118            &component.css_fallback_chains,
1119            available_counts,
1120            &css_owner_label(tag_name),
1121            component.css_content.as_deref(),
1122            out,
1123        );
1124        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
1125        ops.push(TokenGraphOp::EnterFragment(tag_name));
1126    }
1127
1128    fn enter_token_route<'a>(
1129        &'a self,
1130        route: &'a WebUiFragmentRoute,
1131        available_counts: &mut HashMap<String, usize>,
1132        out: &mut UnresolvedTokens,
1133        ops: &mut Vec<TokenGraphOp<'a>>,
1134    ) {
1135        let Some(component) = self.component_registry.get(&route.fragment_id) else {
1136            return;
1137        };
1138        add_token_definitions(&component.css_definitions, available_counts);
1139        record_unresolved_requirements(
1140            &component.css_fallback_chains,
1141            available_counts,
1142            &css_owner_label(&route.fragment_id),
1143            component.css_content.as_deref(),
1144            out,
1145        );
1146        ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
1147        if !route.error_component.is_empty() {
1148            ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
1149        }
1150        if !route.pending_component.is_empty() {
1151            ops.push(TokenGraphOp::EnterComponent(
1152                route.pending_component.as_str(),
1153            ));
1154        }
1155        for child in route.children.iter().rev() {
1156            ops.push(TokenGraphOp::EnterRoute(child));
1157        }
1158        ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
1159    }
1160
1161    fn record_fragment_css_tokens(
1162        &mut self,
1163        definitions: HashSet<String>,
1164        fallback_chains: Vec<CssFallbackChain>,
1165    ) {
1166        let css = self
1167            .fragment_css_tokens
1168            .entry(self.current_fragment_id.clone())
1169            .or_default();
1170        css.definitions.extend(definitions);
1171        css.definitions.sort();
1172        css.definitions.dedup();
1173        css.fallback_chains.extend(fallback_chains);
1174    }
1175
1176    /// Parse HTML content to generate WebUI fragments.
1177    pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
1178        let fragment_key = fragment_id.to_string();
1179        let is_token_root = self.in_progress_fragments.is_empty();
1180        if is_token_root {
1181            // A new top-level entry template starts a fresh boundary
1182            // namespace: sequence numbers are response-local (spec: "0, 1,
1183            // …") and names are only required to be unique per entry. Also
1184            // reset `in_boundary`/`structural_scopes`, which should already
1185            // be empty/false here but could be left dirty if a *previous*
1186            // top-level parse on this same `HtmlParser` errored out midway
1187            // through an open boundary or `<if>`/`<for>` scope.
1188            self.boundary_sequence = 0;
1189            self.boundary_names.clear();
1190            self.boundary_names_in_order.clear();
1191            self.in_boundary = false;
1192            self.body_depth = 0;
1193            self.structural_scopes.clear();
1194            self.foster_context_depth = 0;
1195            self.boundary_parent_scope = None;
1196            self.module_entry_srcs.clear();
1197        }
1198        // Save the caller's fragment id and restore it before returning. A
1199        // component parse recurses through `enter_component_directive`
1200        // (`self.parse(child, …)`); without restoring, the parent would keep
1201        // parsing with `current_fragment_id` pointing at the child, so a later
1202        // diagnostic in the parent would be attributed to the wrong owner.
1203        let previous_fragment_id =
1204            std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
1205        if !self.in_progress_fragments.insert(fragment_key.clone()) {
1206            let err = self
1207                .authoring_error(
1208                    codes::RECURSIVE_TEMPLATE,
1209                    format!("recursive template reference while parsing <{fragment_id}>"),
1210                )
1211                .help(
1212                    "move the recursive usage behind runtime data, or split the component graph \
1213                     so templates do not reference themselves at build time",
1214                )
1215                .into();
1216            self.current_fragment_id = previous_fragment_id;
1217            return Err(err);
1218        }
1219        if is_token_root && !self.token_roots.contains(&fragment_key) {
1220            self.token_roots.push(fragment_key.clone());
1221        }
1222
1223        let result = self.parse_inner(fragment_id, html_content);
1224        self.in_progress_fragments.remove(&fragment_key);
1225        self.current_fragment_id = previous_fragment_id;
1226        result
1227    }
1228
1229    fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
1230        if html_content.len() > MAX_TEMPLATE_BYTES {
1231            return Err(ParserError::Html(format!(
1232                "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.",
1233                html_content.len()
1234            )));
1235        }
1236
1237        // Reset sub-fragments for new parse
1238        self.raw_buffer.clear();
1239        if let Some(ref mut plugin) = self.plugin {
1240            plugin.start_fragment(fragment_id);
1241        }
1242
1243        let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
1244        self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
1245
1246        self.flush_raw_buffer(&mut entry_fragment);
1247
1248        self.fragment_records.insert(
1249            fragment_id.to_string(),
1250            FragmentList {
1251                fragments: entry_fragment,
1252            },
1253        );
1254
1255        Ok(())
1256    }
1257
1258    /// Add raw content to the buffer
1259    fn add_raw_fragment(&mut self, content: &str) {
1260        if !content.is_empty() {
1261            self.raw_buffer.push_str(content);
1262        }
1263    }
1264
1265    /// Add a for fragment, flushing raw buffer first
1266    fn add_for_fragment(
1267        &mut self,
1268        item: String,
1269        collection: String,
1270        fragment_id: String,
1271        fragments: &mut Vec<WebUIFragment>,
1272    ) {
1273        self.flush_raw_buffer(fragments);
1274        fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
1275    }
1276
1277    /// Add an if fragment, flushing raw buffer first
1278    fn add_if_fragment(
1279        &mut self,
1280        condition: ConditionExpr,
1281        fragment_id: String,
1282        fragments: &mut Vec<WebUIFragment>,
1283    ) {
1284        self.flush_raw_buffer(fragments);
1285        fragments.push(WebUIFragment::if_cond(condition, fragment_id));
1286    }
1287
1288    /// Add a non-raw fragment, flushing the raw buffer first if needed
1289    fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
1290        self.flush_raw_buffer(fragments);
1291        fragments.push(fragment);
1292    }
1293
1294    /// Flush the raw buffer into fragments if not empty
1295    fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
1296        if !self.raw_buffer.is_empty() {
1297            fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
1298        }
1299    }
1300
1301    /// Returns true when a text node should be emitted into the fragment stream.
1302    ///
1303    /// Pure formatting runs that contain line breaks are still dropped, but
1304    /// inline whitespace-only separators such as the spaces around `&gt;` in
1305    /// `{{sectionName}} &gt; {{topicName}}` must be preserved.
1306    fn should_emit_text_content(content: &str) -> bool {
1307        if content.is_empty() {
1308            return false;
1309        }
1310
1311        if !content.trim().is_empty() {
1312            return true;
1313        }
1314
1315        content.chars().all(char::is_whitespace)
1316            && !content.contains('\n')
1317            && !content.contains('\r')
1318    }
1319
1320    fn parse_range(
1321        &mut self,
1322        source: &str,
1323        range: Range<usize>,
1324        fragments: &mut Vec<WebUIFragment>,
1325        depth: usize,
1326    ) -> Result<()> {
1327        let mut ops = vec![ParseOp::Parse { range, depth }];
1328
1329        while let Some(op) = ops.pop() {
1330            match op {
1331                ParseOp::Parse { range, depth } => {
1332                    if depth > MAX_TEMPLATE_DEPTH {
1333                        return Err(self
1334                            .html_error(
1335                                codes::EXCESSIVE_NESTING,
1336                                format!(
1337                                    "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
1338                                ),
1339                                source,
1340                                range.start,
1341                            )
1342                            .help(
1343                                "split deeply nested markup into components, or reduce generated nesting",
1344                            )
1345                            .into());
1346                    }
1347
1348                    let end = range.end;
1349                    let mut index = range.start;
1350                    while index < end {
1351                        let remaining = &source[index..end];
1352                        if remaining.starts_with("<!--") {
1353                            let Some(close) = html::find_comment_close(remaining) else {
1354                                return Err(self
1355                                    .html_error(
1356                                        codes::UNTERMINATED_HTML_COMMENT,
1357                                        "unterminated HTML comment",
1358                                        source,
1359                                        index,
1360                                    )
1361                                    .help("close the comment with `-->`")
1362                                    .into());
1363                            };
1364                            index += close;
1365                            continue;
1366                        }
1367
1368                        if remaining.starts_with("<!") {
1369                            let Some(close) = html::find_declaration_close(remaining) else {
1370                                return Err(self
1371                                    .html_error(
1372                                        codes::UNTERMINATED_HTML_DECLARATION,
1373                                        "unterminated HTML declaration",
1374                                        source,
1375                                        index,
1376                                    )
1377                                    .help("close the declaration with `>`")
1378                                    .into());
1379                            };
1380                            self.add_raw_fragment(&remaining[..close]);
1381                            index += close;
1382                            continue;
1383                        }
1384
1385                        if remaining.starts_with('<') {
1386                            let Some(tag) = html::parse_tag(remaining) else {
1387                                return Err(self
1388                                    .html_error(
1389                                        codes::MALFORMED_HTML_TAG,
1390                                        "malformed HTML tag",
1391                                        source,
1392                                        index,
1393                                    )
1394                                    .help(
1395                                        "close the tag with `>`, or escape a literal `<` as `&lt;`",
1396                                    )
1397                                    .into());
1398                            };
1399                            if tag.closing {
1400                                return Err(self
1401                                    .html_error(
1402                                        codes::UNEXPECTED_CLOSING_TAG,
1403                                        format!("unexpected closing tag </{}>", tag.name),
1404                                        source,
1405                                        index,
1406                                    )
1407                                    .help("remove it, or add the matching opening tag before it")
1408                                    .into());
1409                            }
1410
1411                            let content_start = index + tag.close + 1;
1412                            let (content_end, close_end) = if tag.self_closing
1413                                || html::is_void_element(tag.name)
1414                            {
1415                                (content_start, content_start)
1416                            } else if let Some((close_start, close_end)) =
1417                                html::find_matching_end(remaining, tag.name, tag.close + 1)
1418                            {
1419                                (index + close_start, index + close_end)
1420                            } else {
1421                                return Err(self
1422                                    .html_error(
1423                                        codes::UNCLOSED_HTML_TAG,
1424                                        format!("unclosed <{}> tag", tag.name),
1425                                        source,
1426                                        index,
1427                                    )
1428                                    .help(format!(
1429                                        "add the matching </{}> closing tag, or make the element self-closing",
1430                                        tag.name
1431                                    ))
1432                                    .into());
1433                            };
1434
1435                            let element = Element {
1436                                source,
1437                                start: index,
1438                                tag,
1439                                content_start,
1440                                content_end,
1441                                close_end,
1442                            };
1443
1444                            if close_end < end {
1445                                ops.push(ParseOp::Parse {
1446                                    range: close_end..end,
1447                                    depth,
1448                                });
1449                            }
1450
1451                            match element.name() {
1452                                "for" => {
1453                                    self.enter_for_directive(&element, fragments, depth, &mut ops)?;
1454                                }
1455                                "if" => {
1456                                    self.enter_if_directive(&element, fragments, depth, &mut ops)?;
1457                                }
1458                                "route" => {
1459                                    self.process_route_directive(&element, fragments)?;
1460                                }
1461                                "outlet" => {
1462                                    self.flush_raw_buffer(fragments);
1463                                    fragments.push(WebUIFragment::outlet());
1464                                }
1465                                "boundary" => {
1466                                    self.enter_boundary_directive(
1467                                        &element, fragments, depth, &mut ops,
1468                                    )?;
1469                                }
1470                                name if name.eq_ignore_ascii_case("body") => {
1471                                    self.enter_body_element(&element, fragments, depth, &mut ops)?;
1472                                }
1473                                name if name.eq_ignore_ascii_case("head") => {
1474                                    self.enter_head_element(&element, fragments, depth, &mut ops)?;
1475                                }
1476                                name if name.eq_ignore_ascii_case("webui-hydrate") => {
1477                                    return Err(self.authored_webui_hydrate_error(&element));
1478                                }
1479                                name if name.eq_ignore_ascii_case("style") => {
1480                                    self.process_style_element(&element, fragments)?;
1481                                }
1482                                _ if self.component_registry.contains(element.name()) => {
1483                                    self.enter_component_directive(
1484                                        &element, fragments, depth, &mut ops,
1485                                    )?;
1486                                }
1487                                _ => {
1488                                    self.check_component_typo(&element)?;
1489                                    self.enter_regular_element(
1490                                        &element, fragments, depth, &mut ops,
1491                                    )?;
1492                                }
1493                            }
1494
1495                            break;
1496                        }
1497
1498                        let next = remaining.find('<').unwrap_or(remaining.len());
1499                        let text_end = if next == 0 {
1500                            remaining
1501                                .chars()
1502                                .next()
1503                                .map_or(remaining.len(), char::len_utf8)
1504                        } else {
1505                            next
1506                        };
1507                        self.process_text(&remaining[..text_end], fragments)?;
1508                        index += text_end;
1509                    }
1510                }
1511                ParseOp::EmitClose(name) => {
1512                    self.add_raw_fragment("</");
1513                    self.add_raw_fragment(name);
1514                    self.add_raw_fragment(">");
1515                }
1516                ParseOp::EndHead(name) => {
1517                    self.flush_raw_buffer(fragments);
1518                    fragments.push(structural_signal("head_end"));
1519                    self.add_raw_fragment("</");
1520                    self.add_raw_fragment(name);
1521                    self.add_raw_fragment(">");
1522                }
1523                ParseOp::EndBody(name) => {
1524                    self.flush_raw_buffer(fragments);
1525                    fragments.push(structural_signal("body_end"));
1526                    self.add_raw_fragment("</");
1527                    self.add_raw_fragment(name);
1528                    self.add_raw_fragment(">");
1529                    self.body_depth = self.body_depth.saturating_sub(1);
1530                }
1531                ParseOp::EndBoundary(sequence) => {
1532                    self.flush_raw_buffer(fragments);
1533                    fragments.push(structural_signal(format!("boundary_end:{sequence}")));
1534                    self.in_boundary = false;
1535                }
1536                ParseOp::RestoreFosterDepth(previous) => {
1537                    self.foster_context_depth = previous;
1538                }
1539                ParseOp::RestoreBoundaryParentScope(previous) => {
1540                    self.boundary_parent_scope = previous;
1541                }
1542                ParseOp::CompleteFor {
1543                    parent,
1544                    item,
1545                    collection,
1546                    fragment_id,
1547                    keep_empty,
1548                } => {
1549                    self.structural_scopes.pop();
1550                    self.flush_raw_buffer(fragments);
1551                    let for_fragment = std::mem::take(fragments);
1552                    *fragments = parent.fragments;
1553                    self.raw_buffer = parent.raw_buffer;
1554
1555                    if !for_fragment.is_empty() {
1556                        self.fragment_records.insert(
1557                            fragment_id.clone(),
1558                            FragmentList {
1559                                fragments: for_fragment,
1560                            },
1561                        );
1562                    } else if !keep_empty {
1563                        continue;
1564                    }
1565
1566                    self.add_for_fragment(item, collection, fragment_id, fragments);
1567                }
1568                ParseOp::CompleteIf {
1569                    parent,
1570                    condition,
1571                    fragment_id,
1572                } => {
1573                    self.structural_scopes.pop();
1574                    self.flush_raw_buffer(fragments);
1575                    let if_fragment = std::mem::take(fragments);
1576                    *fragments = parent.fragments;
1577                    self.raw_buffer = parent.raw_buffer;
1578
1579                    self.fragment_records.insert(
1580                        fragment_id.clone(),
1581                        FragmentList {
1582                            fragments: if_fragment,
1583                        },
1584                    );
1585                    self.add_if_fragment(condition, fragment_id, fragments);
1586                }
1587            }
1588        }
1589
1590        Ok(())
1591    }
1592
1593    fn enter_regular_element<'a>(
1594        &mut self,
1595        element: &Element<'a>,
1596        fragments: &mut Vec<WebUIFragment>,
1597        depth: usize,
1598        ops: &mut Vec<ParseOp<'a>>,
1599    ) -> Result<()> {
1600        if !self.in_boundary && element.name().eq_ignore_ascii_case("script") {
1601            self.record_module_entry(element);
1602        }
1603        self.add_raw_fragment("<");
1604        self.add_raw_fragment(element.name());
1605
1606        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
1607        if let Some(ref mut p) = self.plugin {
1608            if let Some(data) = p.finish_element(binding_count) {
1609                self.add_fragment(WebUIFragment::plugin(data), fragments);
1610            }
1611        }
1612
1613        if element.self_closing() {
1614            self.add_raw_fragment("/>");
1615            return Ok(());
1616        }
1617
1618        self.add_raw_fragment(">");
1619        if !element.is_void() {
1620            if element.close_end() > element.content_end() {
1621                ops.push(ParseOp::EmitClose(element.name()));
1622            }
1623            self.enter_foster_context(element.name(), ops);
1624            self.enter_boundary_parent_scope(element.name(), ops);
1625            ops.push(ParseOp::Parse {
1626                range: element.inner(),
1627                depth: depth + 1,
1628            });
1629        }
1630        Ok(())
1631    }
1632
1633    /// Record an authored `<script type="module" src="...">` as a critical
1634    /// module entry, so the build can preload the chunks it statically imports.
1635    ///
1636    /// Only bare, static `src` values qualify. A `{{binding}}` resolves per
1637    /// request and cannot be matched to a build artifact, and a script without
1638    /// `type="module"` has no ES module graph to preload. Both are skipped
1639    /// silently rather than diagnosed, because either is a perfectly valid
1640    /// thing to author — they just carry no preload information.
1641    ///
1642    /// The caller has already excluded scripts inside a `<boundary>`.
1643    fn record_module_entry(&mut self, element: &Element<'_>) {
1644        let Some(src) = element.attr("src") else {
1645            return;
1646        };
1647        if src.is_empty() || src.contains("{{") {
1648            return;
1649        }
1650        if !element
1651            .attr("type")
1652            .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
1653        {
1654            return;
1655        }
1656        // Document order is meaningful and duplicates are near-impossible, so
1657        // this stays a `Vec` scan rather than paying for a set.
1658        if self.module_entry_srcs.iter().any(|seen| seen == src) {
1659            return;
1660        }
1661        self.module_entry_srcs.push(src.to_string());
1662    }
1663
1664    fn enter_head_element<'a>(
1665        &mut self,
1666        element: &Element<'a>,
1667        fragments: &mut Vec<WebUIFragment>,
1668        depth: usize,
1669        ops: &mut Vec<ParseOp<'a>>,
1670    ) -> Result<()> {
1671        if element.name() == "head" {
1672            // Preserve the lowercase legacy protocol shape exactly: authored
1673            // attributes on `<head>` were ignored and the opening was emitted
1674            // as the literal `<head>`.
1675            self.add_raw_fragment("<head>");
1676        } else {
1677            // origin/main treated mixed-case native tags as ordinary elements.
1678            // Keep their authored name and attributes while adding only the
1679            // separate case-insensitive streaming structure.
1680            self.add_raw_fragment("<");
1681            self.add_raw_fragment(element.name());
1682            let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
1683            if let Some(ref mut p) = self.plugin {
1684                if let Some(data) = p.finish_element(binding_count) {
1685                    self.add_fragment(WebUIFragment::plugin(data), fragments);
1686                }
1687            }
1688            self.add_raw_fragment(">");
1689        }
1690        self.flush_raw_buffer(fragments);
1691        // Mirrors `body_start`: emitted immediately after the raw opening
1692        // `<head>` fragment and before any child content (including
1693        // authored async scripts), so `render_streaming` can preflight on it.
1694        // Raw signals unknown to the legacy (non-streaming) handler are
1695        // ignored there, so this is a no-op for existing output.
1696        fragments.push(structural_signal("head_start"));
1697        ops.push(ParseOp::EndHead(element.name()));
1698        ops.push(ParseOp::Parse {
1699            range: element.inner(),
1700            depth: depth + 1,
1701        });
1702        Ok(())
1703    }
1704
1705    fn enter_body_element<'a>(
1706        &mut self,
1707        element: &Element<'a>,
1708        fragments: &mut Vec<WebUIFragment>,
1709        depth: usize,
1710        ops: &mut Vec<ParseOp<'a>>,
1711    ) -> Result<()> {
1712        self.add_raw_fragment("<");
1713        self.add_raw_fragment(element.name());
1714        let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
1715        if let Some(ref mut p) = self.plugin {
1716            if let Some(data) = p.finish_element(binding_count) {
1717                self.add_fragment(WebUIFragment::plugin(data), fragments);
1718            }
1719        }
1720        self.add_raw_fragment(">");
1721        self.flush_raw_buffer(fragments);
1722        fragments.push(structural_signal("body_start"));
1723        self.body_depth += 1;
1724        ops.push(ParseOp::EndBody(element.name()));
1725        ops.push(ParseOp::Parse {
1726            range: element.inner(),
1727            depth: depth + 1,
1728        });
1729        Ok(())
1730    }
1731
1732    /// Start an authoring [`Diagnostic`] for the template currently being
1733    /// parsed, naming the owning fragment (entry file or component tag) when
1734    /// known. `code` is the stable machine-readable [`code`](crate::diagnostic::codes).
1735    ///
1736    /// Marked `#[cold]`/`#[inline(never)]`: this only runs while *building* a
1737    /// build error, so keeping it out-of-line preserves hot parse-path layout.
1738    #[cold]
1739    #[inline(never)]
1740    fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
1741        let diag = Diagnostic::error(title).code(code);
1742        if self.current_fragment_id.is_empty() {
1743            diag
1744        } else {
1745            diag.component(self.current_fragment_id.clone())
1746        }
1747    }
1748
1749    /// Like [`HtmlParser::authoring_error`], but also records the source
1750    /// position (line/column) of `element` so the diagnostic can point at the
1751    /// exact spot in the template.
1752    #[cold]
1753    #[inline(never)]
1754    fn authoring_error_at(
1755        &self,
1756        code: &'static str,
1757        title: impl Into<String>,
1758        element: &Element<'_>,
1759    ) -> Diagnostic {
1760        self.authoring_error(code, title)
1761            .at_offset(element.source(), element.start)
1762    }
1763
1764    /// Build a positioned [`Diagnostic`] for a structural HTML well-formedness
1765    /// error (unclosed/malformed tag, unterminated comment, …), naming the
1766    /// owning fragment and the source position from a byte `offset`.
1767    #[cold]
1768    #[inline(never)]
1769    fn html_error(
1770        &self,
1771        code: &'static str,
1772        title: impl Into<String>,
1773        source: &str,
1774        offset: usize,
1775    ) -> Diagnostic {
1776        self.authoring_error(code, title).at_offset(source, offset)
1777    }
1778
1779    /// Build the `help:` line for an unknown component `<name>`.
1780    ///
1781    /// Offers the closest registered component as a "did you mean …?" fix when
1782    /// one is a near typo; otherwise falls back to the generic registration
1783    /// hint.
1784    #[cold]
1785    #[inline(never)]
1786    fn unknown_component_help(&self, name: &str) -> String {
1787        match suggest::closest_match(name, self.component_registry.names()) {
1788            Some(suggestion) => format!(
1789                "did you mean <{suggestion}>? otherwise register the component by adding a \
1790                 matching .html file"
1791            ),
1792            None => "register the component (add a matching .html file) or check the tag name \
1793                     for a typo"
1794                .to_string(),
1795        }
1796    }
1797
1798    /// Suggest a registered component that an unregistered custom-element `tag`
1799    /// likely mistypes.
1800    ///
1801    /// Only same-namespace candidates are considered — the text before the
1802    /// first `-` must match exactly — so a genuine third-party custom element
1803    /// (`<md-button>` when `<mp-button>` is registered) is never flagged, while
1804    /// an in-namespace slip (`<mp-buton>` → `<mp-button>`) is. Returns `None`
1805    /// for non-hyphenated (native) tags and when no near match exists.
1806    fn suggest_component(&self, tag: &str) -> Option<&str> {
1807        let (prefix, _) = tag.split_once('-')?;
1808        let same_namespace = self
1809            .component_registry
1810            .names()
1811            .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
1812        suggest::closest_match(tag, same_namespace)
1813    }
1814
1815    /// Error with a "did you mean …?" hint when `element` is an unregistered
1816    /// custom-element tag that closely matches a registered component in the
1817    /// same namespace. Genuine external custom elements pass through as raw
1818    /// HTML (returns `Ok`).
1819    fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
1820        if let Some(suggestion) = self.suggest_component(element.name()) {
1821            return Err(self
1822                .authoring_error_at(
1823                    codes::UNKNOWN_COMPONENT,
1824                    format!("unknown component <{}>", element.name()),
1825                    element,
1826                )
1827                .help(format!(
1828                    "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
1829                     .html file",
1830                    element.name()
1831                ))
1832                .into());
1833        }
1834        Ok(())
1835    }
1836
1837    /// Name of an attribute on `element` that looks like a typo of `expected`
1838    /// (close edit distance, not an exact match), if any. Used to suggest the
1839    /// intended directive attribute (e.g. `eahc` → `each`).
1840    #[cold]
1841    #[inline(never)]
1842    fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
1843        suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
1844            .filter(|&name| name != expected)
1845    }
1846
1847    /// Promote a standalone [`ParserError::Css`] into a structured authoring
1848    /// [`Diagnostic`] (code [`codes::INVALID_CSS`], owning fragment), so CSS
1849    /// mistakes render and serialize like every other authoring error. The
1850    /// message already carries the in-`<style>` line/column. Non-CSS errors
1851    /// pass through unchanged.
1852    #[cold]
1853    #[inline(never)]
1854    fn css_diagnostic(&self, err: ParserError) -> ParserError {
1855        match err {
1856            ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
1857            other => other,
1858        }
1859    }
1860
1861    /// Build the error for a `<for>` missing its `each` attribute (cold path).
1862    #[cold]
1863    #[inline(never)]
1864    fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
1865        let diag = self
1866            .authoring_error_at(
1867                codes::MISSING_FOR_EACH,
1868                "missing each attribute on <for>",
1869                element,
1870            )
1871            .element("for");
1872        match Self::attr_typo_suggestion(element, "each") {
1873            Some(typo) => diag.help(format!(
1874                "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
1875            )),
1876            None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
1877        }
1878        .into()
1879    }
1880
1881    /// Build the error for a malformed `<for each>` expression (cold path).
1882    #[cold]
1883    #[inline(never)]
1884    fn for_each_invalid_error(&self, element: &Element<'_>, each: &str) -> ParserError {
1885        self.authoring_error_at(
1886            codes::INVALID_FOR_EACH,
1887            "invalid <for> each expression",
1888            element,
1889        )
1890        .element("for")
1891        .snippet(format!("each=\"{each}\""))
1892        .help("use the form each=\"item in collection\", e.g. each=\"todo in todos\"")
1893        .into()
1894    }
1895
1896    /// Build the error for a `<for each>` with disallowed identifier characters
1897    /// (cold path).
1898    #[cold]
1899    #[inline(never)]
1900    fn for_identifier_error(&self, element: &Element<'_>, each: &str) -> ParserError {
1901        self.authoring_error_at(
1902            codes::INVALID_FOR_IDENTIFIER,
1903            "invalid identifier in <for> each expression",
1904            element,
1905        )
1906        .element("for")
1907        .snippet(format!("each=\"{each}\""))
1908        .help("item and collection names may use only letters, digits, '_', '-', and '.'")
1909        .into()
1910    }
1911
1912    fn enter_for_directive<'a>(
1913        &mut self,
1914        element: &Element<'a>,
1915        fragments: &mut Vec<WebUIFragment>,
1916        depth: usize,
1917        ops: &mut Vec<ParseOp<'a>>,
1918    ) -> Result<()> {
1919        let each = element
1920            .attr("each")
1921            .map(ToString::to_string)
1922            .ok_or_else(|| self.for_each_missing_error(element))?;
1923
1924        let mut parts = each.split_whitespace();
1925        let (Some(item), Some(in_kw), Some(collection), None) =
1926            (parts.next(), parts.next(), parts.next(), parts.next())
1927        else {
1928            return Err(self.for_each_invalid_error(element, &each));
1929        };
1930        if in_kw != "in" {
1931            return Err(self.for_each_invalid_error(element, &each));
1932        }
1933
1934        let allowed = |s: &str| {
1935            s.chars()
1936                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1937        };
1938        if !allowed(item) || !allowed(collection) {
1939            return Err(self.for_identifier_error(element, &each));
1940        }
1941
1942        let custom_fragment_id = element.attr("template").map(ToString::to_string);
1943        let keep_empty = custom_fragment_id.is_some();
1944        let fragment_id = custom_fragment_id.unwrap_or_else(|| self.id_counter.next_id("for"));
1945        let parent = ParseContext {
1946            fragments: std::mem::take(fragments),
1947            raw_buffer: std::mem::take(&mut self.raw_buffer),
1948        };
1949
1950        self.structural_scopes.push("a <for> loop");
1951        ops.push(ParseOp::CompleteFor {
1952            parent,
1953            item: item.to_string(),
1954            collection: collection.to_string(),
1955            fragment_id,
1956            keep_empty,
1957        });
1958        ops.push(ParseOp::Parse {
1959            range: element.inner(),
1960            depth: depth + 1,
1961        });
1962        Ok(())
1963    }
1964
1965    /// Build the error for an `<if>` missing its `condition` attribute (cold
1966    /// path).
1967    #[cold]
1968    #[inline(never)]
1969    fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
1970        let diag = self
1971            .authoring_error_at(
1972                codes::MISSING_IF_CONDITION,
1973                "missing condition attribute on <if>",
1974                element,
1975            )
1976            .element("if");
1977        match Self::attr_typo_suggestion(element, "condition") {
1978            Some(typo) => diag.help(format!(
1979                "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
1980            )),
1981            None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
1982        }
1983        .into()
1984    }
1985
1986    /// Build the error for a malformed `<if condition>` expression (cold path).
1987    #[cold]
1988    #[inline(never)]
1989    fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
1990        self.authoring_error_at(
1991            codes::INVALID_IF_CONDITION,
1992            "invalid <if> condition expression",
1993            element,
1994        )
1995        .element("if")
1996        .snippet(format!("condition=\"{condition}\""))
1997        .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
1998        .into()
1999    }
2000
2001    fn enter_if_directive<'a>(
2002        &mut self,
2003        element: &Element<'a>,
2004        fragments: &mut Vec<WebUIFragment>,
2005        depth: usize,
2006        ops: &mut Vec<ParseOp<'a>>,
2007    ) -> Result<()> {
2008        let condition_str = element
2009            .attr("condition")
2010            .map(ToString::to_string)
2011            .ok_or_else(|| self.if_condition_missing_error(element))?;
2012
2013        let condition = self
2014            .condition_parser
2015            .parse(&condition_str)
2016            .map_err(|_| self.if_condition_invalid_error(element, &condition_str))?;
2017
2018        self.flush_raw_buffer(fragments);
2019        let parent = ParseContext {
2020            fragments: std::mem::take(fragments),
2021            raw_buffer: std::mem::take(&mut self.raw_buffer),
2022        };
2023        let fragment_id = self.id_counter.next_id("if");
2024
2025        self.structural_scopes.push("an <if> block");
2026        ops.push(ParseOp::CompleteIf {
2027            parent,
2028            condition,
2029            fragment_id,
2030        });
2031        ops.push(ParseOp::Parse {
2032            range: element.inner(),
2033            depth: depth + 1,
2034        });
2035        Ok(())
2036    }
2037
2038    /// Track whether `name` opens (or shields from) an HTML insertion mode
2039    /// that foster-parents unknown elements, pushing a restore op when the
2040    /// depth changes.
2041    ///
2042    /// Called for every regular element, so the common `Transparent` case
2043    /// does no work beyond the classification compare — no op is pushed and
2044    /// no field is written.
2045    #[inline]
2046    fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
2047        match foster_context_of(name) {
2048            FosterContext::Transparent => {}
2049            FosterContext::Hostile => {
2050                ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
2051                self.foster_context_depth += 1;
2052            }
2053            FosterContext::Barrier => {
2054                if self.foster_context_depth != 0 {
2055                    ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
2056                    self.foster_context_depth = 0;
2057                }
2058            }
2059        }
2060    }
2061
2062    #[inline]
2063    fn enter_boundary_parent_scope<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
2064        let Some(scope) = boundary_parent_scope(name) else {
2065            return;
2066        };
2067        ops.push(ParseOp::RestoreBoundaryParentScope(
2068            self.boundary_parent_scope,
2069        ));
2070        self.boundary_parent_scope = Some(scope);
2071    }
2072
2073    #[inline]
2074    fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
2075        ops.push(ParseOp::RestoreBoundaryParentScope(
2076            self.boundary_parent_scope,
2077        ));
2078        self.boundary_parent_scope = Some("component host content");
2079    }
2080
2081    /// Enter a `<boundary name="…">` directive.
2082    ///
2083    /// `<boundary>` is a reserved, compile-time-only directive (see
2084    /// "Progressive Streaming Hydration" in `DESIGN.md`): it emits
2085    /// no wrapper element, only `boundary_start:<seq>` /
2086    /// `boundary_end:<seq>` raw signal fragments around its children, mirroring
2087    /// how [`Self::enter_body_element`] brackets its children with
2088    /// `body_start`/`body_end`.
2089    ///
2090    /// # Errors
2091    ///
2092    /// Returns [`ParserError::Template`] when:
2093    /// - the boundary is nested inside another open boundary
2094    ///   ([`codes::NESTED_BOUNDARY`]);
2095    /// - the boundary is outside an open `<body>`
2096    ///   ([`codes::BOUNDARY_OUTSIDE_BODY`]);
2097    /// - the boundary sits in an HTML foster-parenting context such as
2098    ///   `<table>`/`<tbody>`/`<tr>`/`<select>`
2099    ///   ([`codes::BOUNDARY_IN_FOSTER_CONTEXT`]);
2100    /// - the boundary would cut through a reusable component template, an
2101    ///   `<if>`, a `<for>`, or a `<route>` scope instead of independently
2102    ///   wrapping it ([`codes::BOUNDARY_CROSSES_SCOPE`] — see the "Known
2103    ///   limitation" note on [`Self::boundary_scope_error`]);
2104    /// - `name` is missing/empty ([`codes::MISSING_BOUNDARY_NAME`]), dynamic
2105    ///   ([`codes::INVALID_BOUNDARY_NAME`]), or a duplicate within the entry
2106    ///   ([`codes::DUPLICATE_BOUNDARY_NAME`]).
2107    fn enter_boundary_directive<'a>(
2108        &mut self,
2109        element: &Element<'a>,
2110        fragments: &mut Vec<WebUIFragment>,
2111        depth: usize,
2112        ops: &mut Vec<ParseOp<'a>>,
2113    ) -> Result<()> {
2114        // `in_progress_fragments.len() > 1` means parsing has recursed into a
2115        // reusable template (a component usage or a route-referenced
2116        // component) via `self.parse(..)`, as opposed to the outermost entry
2117        // template. The design intentionally permits boundaries only in that
2118        // outermost entry.
2119        if self.in_progress_fragments.len() > 1 {
2120            return Err(self.boundary_scope_error(element, "a reusable component template"));
2121        }
2122        if let Some(scope) = self.structural_scopes.last() {
2123            return Err(self.boundary_scope_error(element, scope));
2124        }
2125        if let Some(scope) = self.boundary_parent_scope {
2126            return Err(self.boundary_scope_error(element, scope));
2127        }
2128        if self.in_boundary {
2129            return Err(self.nested_boundary_error(element));
2130        }
2131        if self.body_depth == 0 {
2132            return Err(self.boundary_outside_body_error(element));
2133        }
2134        if self.foster_context_depth != 0 {
2135            return Err(self.boundary_in_foster_context_error(element));
2136        }
2137
2138        let name = self.validate_boundary_name(element)?;
2139        if !self.boundary_names.insert(name.clone()) {
2140            return Err(self.duplicate_boundary_name_error(element, &name));
2141        }
2142        self.boundary_names_in_order.push(name);
2143
2144        let sequence = self.boundary_sequence;
2145        self.boundary_sequence += 1;
2146        self.in_boundary = true;
2147
2148        self.flush_raw_buffer(fragments);
2149        fragments.push(structural_signal(format!("boundary_start:{sequence}")));
2150
2151        ops.push(ParseOp::EndBoundary(sequence));
2152        ops.push(ParseOp::Parse {
2153            range: element.inner(),
2154            depth: depth + 1,
2155        });
2156        Ok(())
2157    }
2158
2159    /// Validate `<boundary name>`: required, non-empty, and static (no
2160    /// `{{binding}}`).
2161    fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
2162        let name = element.attr("name").unwrap_or_default();
2163        if name.is_empty() {
2164            return Err(self.missing_boundary_name_error(element));
2165        }
2166        if name.contains("{{") {
2167            return Err(self.invalid_boundary_name_error(element, name));
2168        }
2169        Ok(name.to_string())
2170    }
2171
2172    /// Build the error for a `<boundary>` missing its `name` attribute
2173    /// (cold path).
2174    #[cold]
2175    #[inline(never)]
2176    fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
2177        self.authoring_error_at(
2178            codes::MISSING_BOUNDARY_NAME,
2179            "missing name attribute on <boundary>",
2180            element,
2181        )
2182        .element("boundary")
2183        .help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
2184        .into()
2185    }
2186
2187    /// Build the error for a `<boundary name>` that is not a static
2188    /// string literal (cold path).
2189    #[cold]
2190    #[inline(never)]
2191    fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
2192        self.authoring_error_at(
2193            codes::INVALID_BOUNDARY_NAME,
2194            "invalid name attribute on <boundary>",
2195            element,
2196        )
2197        .element("boundary")
2198        .snippet(format!("name=\"{name}\""))
2199        .help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
2200        .into()
2201    }
2202
2203    /// Build the error for a duplicate `<boundary name>` within the
2204    /// same entry template (cold path).
2205    #[cold]
2206    #[inline(never)]
2207    fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
2208        self.authoring_error_at(
2209            codes::DUPLICATE_BOUNDARY_NAME,
2210            format!("duplicate <boundary> name \"{name}\""),
2211            element,
2212        )
2213        .element("boundary")
2214        .help("boundary names must be unique within the page; rename one of the boundaries")
2215        .into()
2216    }
2217
2218    /// Build the error for a `<boundary>` nested inside another open
2219    /// boundary (cold path).
2220    #[cold]
2221    #[inline(never)]
2222    fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
2223        self.authoring_error_at(
2224            codes::NESTED_BOUNDARY,
2225            "nested <boundary> is not allowed",
2226            element,
2227        )
2228        .element("boundary")
2229        .help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
2230        .into()
2231    }
2232
2233    /// Build the error for a `<boundary>` that would cut through
2234    /// `scope` (a reusable component template, an `<if>`/`<for>` block, or a
2235    /// `<route>` element) instead of independently wrapping it (cold path).
2236    ///
2237    /// The parser rejects boundaries in every recursively parsed template,
2238    /// including route shells: only the outermost entry passed to
2239    /// [`Self::parse`] may contain one.
2240    #[cold]
2241    #[inline(never)]
2242    fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
2243        self.authoring_error_at(
2244            codes::BOUNDARY_CROSSES_SCOPE,
2245            format!("<boundary> cannot appear inside {scope}"),
2246            element,
2247        )
2248        .element("boundary")
2249        .help("move the <boundary> so it fully wraps this scope from the entry template, instead of appearing inside it")
2250        .into()
2251    }
2252
2253    /// Build the error for a `<boundary>` outside an open body.
2254    #[cold]
2255    #[inline(never)]
2256    fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
2257        self.authoring_error_at(
2258            codes::BOUNDARY_OUTSIDE_BODY,
2259            "<boundary> must appear inside <body>",
2260            element,
2261        )
2262        .element("boundary")
2263        .help("move the <boundary> between the opening <body> and its matching </body>")
2264        .into()
2265    }
2266
2267    /// Build the error for a `<boundary>` inside an HTML
2268    /// foster-parenting insertion mode (cold path).
2269    ///
2270    /// The browser would move the generated `<webui-hydrate>` sentinel out of
2271    /// the table while leaving its payload `<script>` behind, so the
2272    /// coordinator could not pair them and would halt hydration for the whole
2273    /// page. Rejecting at build time keeps that failure impossible.
2274    #[cold]
2275    #[inline(never)]
2276    fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
2277        self.authoring_error_at(
2278            codes::BOUNDARY_IN_FOSTER_CONTEXT,
2279            "<boundary> cannot appear inside a table or select context",
2280            element,
2281        )
2282        .element("boundary")
2283        .help(
2284            "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>",
2285        )
2286        .into()
2287    }
2288
2289    /// Build the error for an authored `<webui-hydrate>` (cold path).
2290    /// `<webui-hydrate>` is reserved for the compiler/handler-generated
2291    /// runtime hydration sentinel and must never appear in authored markup.
2292    #[cold]
2293    #[inline(never)]
2294    fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
2295        self.authoring_error_at(
2296            codes::AUTHORED_WEBUI_HYDRATE,
2297            "<webui-hydrate> is reserved for the generated runtime sentinel",
2298            element,
2299        )
2300        .element("webui-hydrate")
2301        .help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
2302        .into()
2303    }
2304
2305    fn enter_component_directive<'a>(
2306        &mut self,
2307        element: &Element<'a>,
2308        fragments: &mut Vec<WebUIFragment>,
2309        depth: usize,
2310        ops: &mut Vec<ParseOp<'a>>,
2311    ) -> Result<()> {
2312        self.add_raw_fragment("<");
2313        self.add_raw_fragment(element.name());
2314
2315        let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
2316        if let Some(ref mut p) = self.plugin {
2317            if let Some(data) = p.finish_element(binding_count) {
2318                self.add_fragment(WebUIFragment::plugin(data), fragments);
2319            }
2320        }
2321
2322        // Emit a compiler-owned streamed SSR root signal immediately before the
2323        // component opening tag closes. Ordinary rendering ignores it byte-for-byte;
2324        // streaming rendering consumes it to inject ` data-ws` inside the opening tag
2325        // (so the marker exists before custom-element upgrade). Placing the signal
2326        // between the attribute run and the closing `>` is required because an
2327        // attribute cannot be injected once `>` has already been flushed.
2328        self.flush_raw_buffer(fragments);
2329        fragments.push(structural_signal(format!(
2330            "streaming_root:{}",
2331            element.name()
2332        )));
2333
2334        if element.self_closing() {
2335            self.add_raw_fragment("/>");
2336        } else {
2337            self.add_raw_fragment(">");
2338        }
2339
2340        self.flush_raw_buffer(fragments);
2341
2342        let (html_content, css_content) = {
2343            let component = self.component_registry.get(element.name()).ok_or_else(|| {
2344                self.authoring_error_at(
2345                    codes::UNKNOWN_COMPONENT,
2346                    format!("unknown component <{}>", element.name()),
2347                    element,
2348                )
2349                .help(self.unknown_component_help(element.name()))
2350            })?;
2351            (
2352                component.html_content.clone(),
2353                component.css_content.clone(),
2354            )
2355        };
2356
2357        if !self.fragment_records.contains_key(element.name()) {
2358            let component_data = self
2359                .component_registry
2360                .get(element.name())
2361                .ok_or_else(|| {
2362                    self.authoring_error_at(
2363                        codes::UNKNOWN_COMPONENT,
2364                        format!("unknown component <{}>", element.name()),
2365                        element,
2366                    )
2367                    .help(self.unknown_component_help(element.name()))
2368                })?
2369                .clone();
2370            let built = self.build_component_templates(
2371                element.name(),
2372                &html_content,
2373                css_content.as_deref(),
2374                self.plugin.is_some(),
2375            )?;
2376
2377            if let Some(ref mut p) = self.plugin {
2378                p.register_component_template(element.name(), &component_data, built.artifact())?;
2379            }
2380
2381            self.parse(element.name(), &built.ssr)?;
2382        }
2383
2384        fragments.push(WebUIFragment::component(element.name().to_string()));
2385
2386        if !element.self_closing() {
2387            ops.push(ParseOp::EmitClose(element.name()));
2388            self.enter_component_content_scope(ops);
2389            ops.push(ParseOp::Parse {
2390                range: element.inner(),
2391                depth: depth + 1,
2392            });
2393        }
2394
2395        Ok(())
2396    }
2397
2398    fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
2399        if !Self::should_emit_text_content(content) {
2400            return Ok(());
2401        }
2402
2403        let parsed_fragments = self.handlebars_parser.parse(content)?;
2404        for fragment in parsed_fragments {
2405            if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
2406                if Self::should_emit_text_content(&raw.value) {
2407                    self.add_raw_fragment(&raw.value);
2408                }
2409            } else {
2410                self.add_fragment(fragment, fragments);
2411            }
2412        }
2413        Ok(())
2414    }
2415
2416    fn process_style_content(
2417        &mut self,
2418        css: &str,
2419        comments: &[crate::css_parser::CssComment],
2420        fragments: &mut Vec<WebUIFragment>,
2421    ) {
2422        let mut last_end = 0usize;
2423
2424        for comment in comments {
2425            if comment.start_byte < last_end {
2426                if comment.end_byte > last_end {
2427                    last_end = comment.end_byte;
2428                }
2429                continue;
2430            }
2431
2432            self.add_raw_fragment(&css[last_end..comment.start_byte]);
2433            let comment_text = &css[comment.start_byte..comment.end_byte];
2434            if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
2435                self.add_fragment(fragment, fragments);
2436            } else if comment.preserve {
2437                self.add_raw_fragment(comment_text);
2438            }
2439            last_end = comment.end_byte;
2440        }
2441
2442        self.add_raw_fragment(&css[last_end..]);
2443    }
2444
2445    fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
2446        let signal = comment_policy::parse_css_signal_comment(comment)?;
2447        Some(WebUIFragment::signal(signal.path, signal.raw))
2448    }
2449
2450    /// Check if an attribute value is a pure handlebars expression (e.g., "{{name}}" or
2451    /// "{{name}}" with quotes). Returns the inner signal name (borrowed) if so.
2452    fn extract_single_handlebars(value: &str) -> Option<&str> {
2453        let trimmed = value.trim();
2454        if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
2455            let inner = trimmed[2..trimmed.len() - 2].trim();
2456            // Verify there's no other {{ in the middle (i.e., it's truly a single expression)
2457            if !inner.contains("{{") && !inner.is_empty() {
2458                return Some(inner);
2459            }
2460        }
2461        None
2462    }
2463
2464    /// Check if an attribute value contains any handlebars expressions.
2465    fn contains_handlebars(value: &str) -> bool {
2466        value.contains("{{")
2467    }
2468
2469    fn process_tag_attributes(
2470        &mut self,
2471        attrs: Attrs<'_>,
2472        fragments: &mut Vec<WebUIFragment>,
2473        is_component: bool,
2474    ) -> Result<u32> {
2475        let mut first_dynamic_emitted = false;
2476        let mut binding_count: u32 = 0;
2477        for attr in attrs {
2478            let attr_name = attr.name;
2479
2480            if let Some(ref mut p) = self.plugin {
2481                match p.classify_attribute(attr_name) {
2482                    AttributeAction::Keep => {}
2483                    AttributeAction::Skip => continue,
2484                    AttributeAction::SkipAndCountBinding => {
2485                        binding_count += 1;
2486                        continue;
2487                    }
2488                }
2489            }
2490
2491            let attr_value = attr.value;
2492
2493            if let Some(bool_name) = attr_name.strip_prefix('?') {
2494                if is_component {
2495                    if let Some(condition) = self.parse_boolean_condition(attr_value) {
2496                        let frag = Self::maybe_mark_attr_start(
2497                            WebUIFragment::attribute_boolean(bool_name, condition),
2498                            &mut first_dynamic_emitted,
2499                        );
2500                        self.add_fragment(frag, fragments);
2501                        binding_count += 1;
2502                    }
2503                } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
2504                    binding_count += 1;
2505                }
2506            } else if let Some(prop_name) = attr_name.strip_prefix(':') {
2507                if !is_component {
2508                    return Err(ParserError::Parse(format!(
2509                        ":{prop_name} complex binding is only allowed on custom elements. \
2510                         Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
2511                    )));
2512                }
2513                if Self::is_blocked_complex_property(prop_name) {
2514                    return Err(ParserError::Parse(format!(
2515                        ":{prop_name} is not allowed as a complex attribute binding \
2516                         because it enables arbitrary HTML injection. \
2517                         Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
2518                    )));
2519                }
2520                if let Some(val) = attr_value {
2521                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
2522                        let frag = Self::maybe_mark_attr_start(
2523                            WebUIFragment::attribute_complex(attr_name, signal_name),
2524                            &mut first_dynamic_emitted,
2525                        );
2526                        self.add_fragment(frag, fragments);
2527                        binding_count += 1;
2528                    }
2529                }
2530            } else if is_component && Self::is_skipped_attribute(attr_name) {
2531                if let Some(val) = attr_value {
2532                    if let Some(signal_name) = Self::extract_single_handlebars(val) {
2533                        let frag = WebUIFragment {
2534                            fragment: Some(web_ui_fragment::Fragment::Attribute(
2535                                WebUIFragmentAttribute {
2536                                    name: attr_name.to_string(),
2537                                    value: signal_name.to_string(),
2538                                    attr_skip: true,
2539                                    ..Default::default()
2540                                },
2541                            )),
2542                        };
2543                        self.add_fragment(frag, fragments);
2544                        binding_count += 1;
2545                    } else if Self::contains_handlebars(val) {
2546                        let template_id = self.id_counter.next_id("attr");
2547                        let parsed = self.handlebars_parser.parse(val)?;
2548                        self.fragment_records
2549                            .insert(template_id.clone(), FragmentList { fragments: parsed });
2550                        let frag = WebUIFragment {
2551                            fragment: Some(web_ui_fragment::Fragment::Attribute(
2552                                WebUIFragmentAttribute {
2553                                    name: attr_name.to_string(),
2554                                    template: template_id,
2555                                    attr_skip: true,
2556                                    ..Default::default()
2557                                },
2558                            )),
2559                        };
2560                        self.add_fragment(frag, fragments);
2561                        binding_count += 1;
2562                    } else {
2563                        let frag = WebUIFragment {
2564                            fragment: Some(web_ui_fragment::Fragment::Attribute(
2565                                WebUIFragmentAttribute {
2566                                    name: attr_name.to_string(),
2567                                    value: val.to_string(),
2568                                    raw_value: true,
2569                                    attr_skip: true,
2570                                    ..Default::default()
2571                                },
2572                            )),
2573                        };
2574                        self.add_fragment(frag, fragments);
2575                    }
2576                }
2577            } else if let Some(val) = attr_value {
2578                if Self::contains_handlebars(val) {
2579                    if is_component {
2580                        if let Some(signal_name) = Self::extract_single_handlebars(val) {
2581                            let frag = Self::maybe_mark_attr_start(
2582                                WebUIFragment::attribute(attr_name, signal_name),
2583                                &mut first_dynamic_emitted,
2584                            );
2585                            self.add_fragment(frag, fragments);
2586                            binding_count += 1;
2587                        } else {
2588                            let template_id = self.id_counter.next_id("attr");
2589                            let parsed = self.handlebars_parser.parse(val)?;
2590                            self.fragment_records
2591                                .insert(template_id.clone(), FragmentList { fragments: parsed });
2592                            let frag = Self::maybe_mark_attr_start(
2593                                WebUIFragment::attribute_template(attr_name, template_id),
2594                                &mut first_dynamic_emitted,
2595                            );
2596                            self.add_fragment(frag, fragments);
2597                            binding_count += 1;
2598                        }
2599                    } else {
2600                        self.process_dynamic_attribute(attr_name, val, fragments)?;
2601                        binding_count += 1;
2602                    }
2603                } else if is_component {
2604                    let frag = Self::maybe_mark_attr_start(
2605                        WebUIFragment {
2606                            fragment: Some(web_ui_fragment::Fragment::Attribute(
2607                                WebUIFragmentAttribute {
2608                                    name: attr_name.to_string(),
2609                                    value: val.to_string(),
2610                                    raw_value: true,
2611                                    ..Default::default()
2612                                },
2613                            )),
2614                        },
2615                        &mut first_dynamic_emitted,
2616                    );
2617                    self.add_fragment(frag, fragments);
2618                } else {
2619                    self.add_raw_fragment(" ");
2620                    self.add_raw_fragment(attr.raw);
2621                }
2622            } else {
2623                self.add_raw_fragment(" ");
2624                self.add_raw_fragment(attr_name);
2625            }
2626        }
2627        Ok(binding_count)
2628    }
2629
2630    /// Set `attr_start = true` on the first non-skipped attribute fragment for
2631    /// a component element.
2632    fn maybe_mark_attr_start(
2633        mut frag: WebUIFragment,
2634        first_dynamic_emitted: &mut bool,
2635    ) -> WebUIFragment {
2636        if !*first_dynamic_emitted {
2637            if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
2638                a.attr_start = true;
2639            }
2640            *first_dynamic_emitted = true;
2641        }
2642        frag
2643    }
2644
2645    /// Process a boolean attribute (?prefix). Silently drops if value is not a
2646    /// pure handlebars expression.
2647    fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
2648        if let Some(val) = value {
2649            if let Some(expr_str) = Self::extract_single_handlebars(val) {
2650                return Some(
2651                    self.condition_parser
2652                        .parse(expr_str)
2653                        .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
2654                );
2655            }
2656        }
2657
2658        None
2659    }
2660
2661    fn process_boolean_attribute(
2662        &mut self,
2663        name: &str,
2664        value: Option<&str>,
2665        fragments: &mut Vec<WebUIFragment>,
2666    ) -> Result<bool> {
2667        if let Some(condition) = self.parse_boolean_condition(value) {
2668            self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
2669            return Ok(true);
2670        }
2671        // Invalid boolean attribute — silently drop (no output at all)
2672        Ok(false)
2673    }
2674
2675    /// Process a dynamic attribute (regular name with handlebars in value).
2676    fn process_dynamic_attribute(
2677        &mut self,
2678        name: &str,
2679        value: &str,
2680        fragments: &mut Vec<WebUIFragment>,
2681    ) -> Result<()> {
2682        if let Some(signal_name) = Self::extract_single_handlebars(value) {
2683            // Pure handlebars — simple attribute fragment
2684            self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
2685        } else {
2686            // Mixed static + dynamic — create a template sub-stream
2687            let template_id = self.id_counter.next_id("attr");
2688            let parsed = self.handlebars_parser.parse(value)?;
2689
2690            self.fragment_records
2691                .insert(template_id.clone(), FragmentList { fragments: parsed });
2692
2693            self.add_fragment(
2694                WebUIFragment::attribute_template(name, template_id),
2695                fragments,
2696            );
2697        }
2698        Ok(())
2699    }
2700
2701    fn process_style_element(
2702        &mut self,
2703        element: &Element<'_>,
2704        fragments: &mut Vec<WebUIFragment>,
2705    ) -> Result<()> {
2706        self.add_raw_fragment(element.opening());
2707        let inner = element.inner();
2708        let style_content = &element.source()[inner.start..inner.end];
2709        let (_tokens, defs, requirements, comments) = self
2710            .css_parser
2711            .extract_tokens_definitions_requirements_and_comments(
2712                style_content,
2713                self.options.legal_comments,
2714            )
2715            .map_err(|e| self.css_diagnostic(e))?;
2716        self.record_fragment_css_tokens(defs, requirements);
2717        self.process_style_content(style_content, &comments, fragments);
2718        if element.close_end() > element.content_end() {
2719            // Reconstruct the closing tag from the parsed name so the emitted
2720            // case mirrors the source (e.g. `<STYLE>` closes with `</STYLE>`),
2721            // matching how regular elements are closed via `ParseOp::EmitClose`.
2722            self.add_raw_fragment("</");
2723            self.add_raw_fragment(element.name());
2724            self.add_raw_fragment(">");
2725        }
2726        Ok(())
2727    }
2728
2729    /// Process a `<route>` directive.
2730    ///
2731    /// Emits a `Fragment::Route` protocol fragment. The handler renders
2732    /// `<webui-route>` elements with server-side route matching — matched
2733    /// routes get `active` + component content, non-matched get `display:none`.
2734    fn process_route_directive(
2735        &mut self,
2736        element: &Element<'_>,
2737        fragments: &mut Vec<WebUIFragment>,
2738    ) -> Result<()> {
2739        let attrs = Self::route_attrs_from_element(element);
2740        let path = attrs.path.clone();
2741        let component = attrs.component.clone();
2742        route_parser::validate_attributes(&attrs)?;
2743
2744        let route_params: std::collections::HashSet<String> =
2745            route_parser::extract_params(&path)?.into_iter().collect();
2746        if !attrs.cache_tags.is_empty() {
2747            route_parser::validate_tag_placeholders(
2748                &attrs.cache_tags,
2749                &route_params,
2750                "cache-tags",
2751                &path,
2752            )?;
2753        }
2754        if !attrs.invalidates.is_empty() {
2755            route_parser::validate_tag_placeholders(
2756                &attrs.invalidates,
2757                &route_params,
2758                "invalidates",
2759                &path,
2760            )?;
2761        }
2762
2763        if !attrs.pending_component.is_empty() {
2764            self.ensure_route_component_parsed(&attrs.pending_component)?;
2765        }
2766        if !attrs.error_component.is_empty() {
2767            self.ensure_route_component_parsed(&attrs.error_component)?;
2768        }
2769        self.ensure_route_component_parsed(&component)?;
2770
2771        let mut all_params = std::collections::HashSet::new();
2772        all_params.extend(route_params);
2773        self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
2774        let children =
2775            self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
2776
2777        self.flush_raw_buffer(fragments);
2778        let route_fragment =
2779            route_parser::build_route_fragment(&attrs, component.clone(), children);
2780        fragments.push(WebUIFragment::route_from(route_fragment));
2781
2782        Ok(())
2783    }
2784
2785    /// Parse nested `<route>` children into route fragments.
2786    ///
2787    /// `depth` is the nesting level of the routes produced here (top-level
2788    /// `<route>` children are depth 1). Nested routes are validated against
2789    /// [`MAX_TEMPLATE_DEPTH`] so pathological route trees cannot exhaust the
2790    /// call stack via `parse_route_as_fragment` recursion.
2791    fn parse_child_routes(
2792        &mut self,
2793        source: &str,
2794        range: Range<usize>,
2795        ancestor_params: &std::collections::HashSet<String>,
2796        depth: usize,
2797    ) -> Result<Vec<WebUiFragmentRoute>> {
2798        if depth > MAX_TEMPLATE_DEPTH {
2799            return Err(self
2800                .html_error(
2801                    codes::EXCESSIVE_NESTING,
2802                    format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
2803                    source,
2804                    range.start,
2805                )
2806                .help("flatten deeply nested <route> trees before building")
2807                .into());
2808        }
2809
2810        let mut children = Vec::new();
2811        for event in Walker::new_range(source, range.start, range.end) {
2812            match event {
2813                Event::Element(element) => {
2814                    self.validate_closed_element(&element)?;
2815                    self.reject_boundary_or_hydrate_in_route(&element)?;
2816                    if element.name() == "route" {
2817                        children.push(self.parse_route_as_fragment(
2818                            &element,
2819                            ancestor_params,
2820                            depth,
2821                        )?);
2822                    } else if element.name().eq_ignore_ascii_case("style") {
2823                        self.validate_style_element(&element)?;
2824                    } else if !element.self_closing() && !element.is_void() {
2825                        self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
2826                    }
2827                }
2828                Event::Text(text) => {
2829                    if text.contains('<') {
2830                        return Err(self
2831                            .authoring_error(
2832                                codes::MALFORMED_HTML_TAG,
2833                                "malformed HTML tag in <route>",
2834                            )
2835                            .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
2836                            .into());
2837                    }
2838                }
2839                Event::Comment(comment_range) => {
2840                    self.validate_comment_range(source, comment_range)?;
2841                }
2842                Event::Declaration(declaration_range) => {
2843                    self.validate_declaration_range(source, declaration_range)?;
2844                }
2845                Event::ClosingTag(closing_range) => {
2846                    return Err(self.unexpected_closing_tag_error(source, closing_range));
2847                }
2848            }
2849        }
2850
2851        Ok(children)
2852    }
2853
2854    fn validate_route_nesting_depth(
2855        &self,
2856        source: &str,
2857        range: Range<usize>,
2858        depth: usize,
2859    ) -> Result<()> {
2860        let mut stack = vec![(range, depth)];
2861        while let Some((range, depth)) = stack.pop() {
2862            if depth > MAX_TEMPLATE_DEPTH {
2863                return Err(self
2864                    .html_error(
2865                        codes::EXCESSIVE_NESTING,
2866                        format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
2867                        source,
2868                        range.start,
2869                    )
2870                    .help("flatten deeply nested <route> trees before building")
2871                    .into());
2872            }
2873
2874            for event in Walker::new_range(source, range.start, range.end) {
2875                if let Event::Element(element) = event {
2876                    self.validate_closed_element(&element)?;
2877                    if element.name() == "route" && !element.self_closing() {
2878                        stack.push((element.inner(), depth + 1));
2879                    }
2880                }
2881            }
2882        }
2883        Ok(())
2884    }
2885
2886    fn validate_ignored_route_html(
2887        &mut self,
2888        source: &str,
2889        range: Range<usize>,
2890        depth: usize,
2891    ) -> Result<()> {
2892        let mut stack = vec![(range, depth)];
2893        while let Some((range, depth)) = stack.pop() {
2894            if depth > MAX_TEMPLATE_DEPTH {
2895                return Err(self
2896                    .html_error(
2897                        codes::EXCESSIVE_NESTING,
2898                        format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
2899                        source,
2900                        range.start,
2901                    )
2902                    .help("split deeply nested markup into components, or reduce generated nesting")
2903                    .into());
2904            }
2905
2906            for event in Walker::new_range(source, range.start, range.end) {
2907                match event {
2908                    Event::Element(element) => {
2909                        self.validate_closed_element(&element)?;
2910                        self.reject_boundary_or_hydrate_in_route(&element)?;
2911                        if element.name().eq_ignore_ascii_case("style") {
2912                            self.validate_style_element(&element)?;
2913                        } else if !element.self_closing() && !element.is_void() {
2914                            stack.push((element.inner(), depth + 1));
2915                        }
2916                    }
2917                    Event::Text(text) => {
2918                        if text.contains('<') {
2919                            return Err(self
2920                                .authoring_error(
2921                                    codes::MALFORMED_HTML_TAG,
2922                                    "malformed HTML tag in <route>",
2923                                )
2924                                .help("close the tag with `>`, or escape a literal `<` as `&lt;`")
2925                                .into());
2926                        }
2927                    }
2928                    Event::Comment(comment_range) => {
2929                        self.validate_comment_range(source, comment_range)?;
2930                    }
2931                    Event::Declaration(declaration_range) => {
2932                        self.validate_declaration_range(source, declaration_range)?;
2933                    }
2934                    Event::ClosingTag(closing_range) => {
2935                        return Err(self.unexpected_closing_tag_error(source, closing_range));
2936                    }
2937                }
2938            }
2939        }
2940        Ok(())
2941    }
2942
2943    /// Reject `<boundary>`/`<webui-hydrate>` written inside `<route>`
2944    /// markup.
2945    ///
2946    /// `<route>` children are validated by a separate well-formedness walker
2947    /// ([`Self::parse_child_routes`]/[`Self::validate_ignored_route_html`]),
2948    /// not the main fragment dispatch in [`Self::parse_range`], so both
2949    /// reserved directives need an explicit check here to produce the same
2950    /// structured diagnostics as everywhere else instead of silently passing
2951    /// through as ignored HTML.
2952    fn reject_boundary_or_hydrate_in_route(&self, element: &Element<'_>) -> Result<()> {
2953        if element.name() == "boundary" {
2954            Err(self.boundary_scope_error(element, "a <route> element"))
2955        } else if element.name().eq_ignore_ascii_case("webui-hydrate") {
2956            Err(self.authored_webui_hydrate_error(element))
2957        } else {
2958            Ok(())
2959        }
2960    }
2961
2962    fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
2963        if !element.self_closing()
2964            && !element.is_void()
2965            && element.close_end() == element.content_end()
2966        {
2967            return Err(self
2968                .html_error(
2969                    codes::UNCLOSED_HTML_TAG,
2970                    format!("unclosed <{}> tag", element.name()),
2971                    element.source(),
2972                    element.start,
2973                )
2974                .help(format!(
2975                    "add the matching </{}> closing tag, or make the element self-closing",
2976                    element.name()
2977                ))
2978                .into());
2979        }
2980        Ok(())
2981    }
2982
2983    fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
2984        let inner = element.inner();
2985        let style_content = &element.source()[inner.start..inner.end];
2986        self.css_parser
2987            .extract_tokens_definitions_requirements_and_comments(
2988                style_content,
2989                self.options.legal_comments,
2990            )
2991            .map_err(|e| self.css_diagnostic(e))?;
2992        Ok(())
2993    }
2994
2995    fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
2996        let start = range.start;
2997        if source[range].ends_with("-->") {
2998            return Ok(());
2999        }
3000        Err(self
3001            .html_error(
3002                codes::UNTERMINATED_HTML_COMMENT,
3003                "unterminated HTML comment in <route>",
3004                source,
3005                start,
3006            )
3007            .help("close the comment with `-->`")
3008            .into())
3009    }
3010
3011    fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
3012        let start = range.start;
3013        if source[range].ends_with('>') {
3014            return Ok(());
3015        }
3016        Err(self
3017            .html_error(
3018                codes::UNTERMINATED_HTML_DECLARATION,
3019                "unterminated HTML declaration in <route>",
3020                source,
3021                start,
3022            )
3023            .help("close the declaration with `>`")
3024            .into())
3025    }
3026
3027    fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
3028        let start = range.start;
3029        let title = match html::parse_tag(&source[range]) {
3030            Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
3031            None => "unexpected closing tag in <route>".to_string(),
3032        };
3033        self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
3034            .help("remove it, or add the matching opening tag before it")
3035            .into()
3036    }
3037
3038    fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
3039        route_parser::RouteAttributes {
3040            path: element.attr("path").unwrap_or_default().to_string(),
3041            component: element.attr("component").unwrap_or_default().to_string(),
3042            exact: element.has_attr("exact"),
3043            query: element.attr("query").unwrap_or_default().to_string(),
3044            keep_alive: element.has_attr("keep-alive"),
3045            cache_tags: element
3046                .attr("cache-tags")
3047                .map(route_parser::parse_tag_list)
3048                .unwrap_or_default(),
3049            invalidates: element
3050                .attr("invalidates")
3051                .map(route_parser::parse_tag_list)
3052                .unwrap_or_default(),
3053            pending_component: element.attr("pending").unwrap_or_default().to_string(),
3054            error_component: element.attr("error").unwrap_or_default().to_string(),
3055        }
3056    }
3057
3058    fn parse_route_as_fragment(
3059        &mut self,
3060        element: &Element<'_>,
3061        ancestor_params: &std::collections::HashSet<String>,
3062        depth: usize,
3063    ) -> Result<WebUiFragmentRoute> {
3064        let attrs = Self::route_attrs_from_element(element);
3065        let path = attrs.path.clone();
3066        let component = attrs.component.clone();
3067
3068        route_parser::validate_attributes(&attrs)?;
3069
3070        let own_params: std::collections::HashSet<String> =
3071            route_parser::extract_params(&path)?.into_iter().collect();
3072        let mut all_params = ancestor_params.clone();
3073        all_params.extend(own_params.iter().cloned());
3074        if !attrs.cache_tags.is_empty() {
3075            route_parser::validate_tag_placeholders(
3076                &attrs.cache_tags,
3077                &all_params,
3078                "cache-tags",
3079                &path,
3080            )?;
3081        }
3082        if !attrs.invalidates.is_empty() {
3083            route_parser::validate_tag_placeholders(
3084                &attrs.invalidates,
3085                &all_params,
3086                "invalidates",
3087                &path,
3088            )?;
3089        }
3090
3091        if !attrs.pending_component.is_empty() {
3092            self.ensure_route_component_parsed(&attrs.pending_component)?;
3093        }
3094        if !attrs.error_component.is_empty() {
3095            self.ensure_route_component_parsed(&attrs.error_component)?;
3096        }
3097
3098        self.ensure_route_component_parsed(&component)?;
3099        let children =
3100            self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
3101
3102        Ok(route_parser::build_route_fragment(
3103            &attrs, component, children,
3104        ))
3105    }
3106
3107    /// Ensure a route-referenced component is parsed and registered.
3108    fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
3109        if component.is_empty()
3110            || !self.component_registry.contains(component)
3111            || self.fragment_records.contains_key(component)
3112        {
3113            return Ok(());
3114        }
3115
3116        let component_data = self
3117            .component_registry
3118            .get(component)
3119            .ok_or_else(|| {
3120                self.authoring_error(
3121                    codes::UNKNOWN_COMPONENT,
3122                    format!("unknown component <{component}>"),
3123                )
3124                .help(self.unknown_component_help(component))
3125            })?
3126            .clone();
3127
3128        let built = self.build_component_templates(
3129            component,
3130            &component_data.html_content,
3131            component_data.css_content.as_deref(),
3132            self.plugin.is_some(),
3133        )?;
3134
3135        if let Some(ref mut p) = self.plugin {
3136            p.register_component_template(component, &component_data, built.artifact())?;
3137        }
3138
3139        let saved_buffer = std::mem::take(&mut self.raw_buffer);
3140        self.parse(component, &built.ssr)?;
3141        self.raw_buffer = saved_buffer;
3142
3143        Ok(())
3144    }
3145
3146    /// Skipped attribute names for components.
3147    const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
3148    /// Skipped attribute prefixes for components.
3149    const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-", "aria-"];
3150    const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
3151
3152    fn is_skipped_attribute(name: &str) -> bool {
3153        Self::SKIPPED_ATTRIBUTES.contains(&name)
3154            || Self::SKIPPED_ATTRIBUTE_PREFIXES
3155                .iter()
3156                .any(|prefix| name.starts_with(prefix))
3157    }
3158
3159    /// Properties that must never be set via `:attr` complex bindings.
3160    /// These enable XSS (HTML injection) or arbitrary code execution.
3161    fn is_blocked_complex_property(name: &str) -> bool {
3162        matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
3163    }
3164
3165    /// Build both SSR-facing and plugin-facing component template views.
3166    fn build_component_templates(
3167        &mut self,
3168        tag_name: &str,
3169        html: &str,
3170        css_content: Option<&str>,
3171        artifact_needed: bool,
3172    ) -> Result<BuiltComponentTemplate> {
3173        let adopted_specifier = match self.options.css_strategy {
3174            CssStrategy::Module if css_content.is_some() => Some(tag_name),
3175            _ => None,
3176        };
3177        let css_injection = match self.options.css_strategy {
3178            CssStrategy::Link => {
3179                // In light DOM mode, CSS links go in <head> (emitted by handler),
3180                // not inside each component template.
3181                if let (Some(css), DomStrategy::Shadow) = (css_content, self.options.dom_strategy) {
3182                    let href = self.options.css_link_options.resolve(tag_name, css);
3183                    let mut link = String::with_capacity(31 + href.href.len());
3184                    link.push_str("<link rel=\"stylesheet\" href=\"");
3185                    link.push_str(&href.href);
3186                    link.push_str("\">");
3187                    Some(link)
3188                } else {
3189                    None
3190                }
3191            }
3192            CssStrategy::Style => css_content.map(|css| {
3193                let trimmed = css.trim();
3194                let mut style = String::with_capacity(15 + trimmed.len());
3195                style.push_str("<style>");
3196                style.push_str(trimmed);
3197                style.push_str("</style>");
3198                style
3199            }),
3200            CssStrategy::Module => None,
3201        };
3202
3203        let artifact_differs = artifact_needed && Self::template_has_stripped_runtime_attrs(html);
3204        let ssr =
3205            self.process_component_template(html, css_injection.as_deref(), adopted_specifier)?;
3206        let artifact = if artifact_differs {
3207            Some(self.process_component_artifact_template(
3208                html,
3209                css_injection.as_deref(),
3210                adopted_specifier,
3211            )?)
3212        } else {
3213            None
3214        };
3215
3216        Ok(BuiltComponentTemplate { ssr, artifact })
3217    }
3218
3219    /// Process component template HTML for SSR output.
3220    ///
3221    /// The developer's authored `<template>` wrapper is the source of truth.
3222    ///
3223    /// - **Dev supplied `<template ...>`:** preserved verbatim — including
3224    ///   `shadowrootmode`, `shadowrootadoptedstylesheets`, signal fragments,
3225    ///   and any other custom attributes. SSR strips runtime-only attributes
3226    ///   (`@event`, `:bind`, `?cond`) from the opening tag, since those are
3227    ///   protocol metadata and never appear in HTML output. Plugin-facing
3228    ///   artifacts preserve them. If a CSS snippet is supplied, it is injected
3229    ///   immediately inside the opening tag (before the dev's children) so
3230    ///   styles still apply. For `CssStrategy::Module`, the parser appends
3231    ///   `shadowrootadoptedstylesheets="<tag>"` when it is missing.
3232    ///
3233    /// - **Dev omitted `<template>`:**
3234    ///   - `DomStrategy::Shadow` wraps the content in a framework-controlled
3235    ///     `<template shadowrootmode="open">`, optionally adding
3236    ///     `shadowrootadoptedstylesheets="<tag>"` for the CSS-module strategy.
3237    ///   - `DomStrategy::Light` emits the content as-is (with the CSS snippet
3238    ///     prepended, if any).
3239    ///
3240    /// Performance: zero recursion, zero regex. The dev-template path uses
3241    /// quote-aware scanners for opening-tag queries and pre-sizes the output
3242    /// buffer to avoid reallocation in the hot path.
3243    fn process_component_template(
3244        &mut self,
3245        html: &str,
3246        css_snippet: Option<&str>,
3247        adopted_specifier: Option<&str>,
3248    ) -> Result<String> {
3249        self.process_component_template_with_mode(html, css_snippet, adopted_specifier, false)
3250    }
3251
3252    fn process_component_artifact_template(
3253        &mut self,
3254        html: &str,
3255        css_snippet: Option<&str>,
3256        adopted_specifier: Option<&str>,
3257    ) -> Result<String> {
3258        self.process_component_template_with_mode(html, css_snippet, adopted_specifier, true)
3259    }
3260
3261    fn process_component_template_with_mode(
3262        &mut self,
3263        html: &str,
3264        css_snippet: Option<&str>,
3265        adopted_specifier: Option<&str>,
3266        preserve_runtime_attrs: bool,
3267    ) -> Result<String> {
3268        let trimmed = html.trim();
3269        let snippet = css_snippet.unwrap_or_default();
3270
3271        let processed = if trimmed.starts_with("<template") {
3272            let base = if preserve_runtime_attrs {
3273                trimmed.to_string()
3274            } else {
3275                self.strip_runtime_attrs_from_template(trimmed)
3276            };
3277            let with_adopted = Self::append_adopted_attr_if_missing(base, adopted_specifier);
3278            Self::inject_css_snippet_into_template(with_adopted, snippet)
3279        } else {
3280            match self.options.dom_strategy {
3281                DomStrategy::Shadow => {
3282                    let adopted = adopted_specifier.unwrap_or_default();
3283                    let adopted_extra = if adopted.is_empty() {
3284                        0
3285                    } else {
3286                        Self::adopted_attr_len(adopted)
3287                    };
3288                    let mut result =
3289                        String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
3290                    result.push_str("<template shadowrootmode=\"open\"");
3291                    if !adopted.is_empty() {
3292                        Self::push_adopted_attr(&mut result, adopted);
3293                    }
3294                    result.push('>');
3295                    result.push_str(snippet);
3296                    result.push_str(trimmed);
3297                    result.push_str("</template>");
3298                    result
3299                }
3300                DomStrategy::Light => {
3301                    if snippet.is_empty() {
3302                        trimmed.to_string()
3303                    } else {
3304                        let mut result = String::with_capacity(snippet.len() + trimmed.len());
3305                        result.push_str(snippet);
3306                        result.push_str(trimmed);
3307                        result
3308                    }
3309                }
3310            }
3311        };
3312
3313        self.strip_template_comments(processed)
3314    }
3315
3316    fn inject_css_snippet_into_template(html: String, snippet: &str) -> String {
3317        if snippet.is_empty() {
3318            return html;
3319        }
3320
3321        match html::find_tag_close(&html) {
3322            Some(open_end) => {
3323                let mut result = String::with_capacity(html.len() + snippet.len());
3324                result.push_str(&html[..=open_end]);
3325                result.push_str(snippet);
3326                result.push_str(&html[open_end + 1..]);
3327                result
3328            }
3329            // Malformed opening tag (no closing `>`): emit as-is rather than
3330            // panic. The downstream parser surfaces the error.
3331            None => html,
3332        }
3333    }
3334
3335    fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
3336        let Some(adopted) = adopted_specifier else {
3337            return html;
3338        };
3339        let Some(tag) = html::parse_tag(&html) else {
3340            return html;
3341        };
3342        if tag.name != "template" || tag.closing || tag.has_attr(Self::ADOPTED_STYLESHEETS_ATTR) {
3343            return html;
3344        }
3345
3346        let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
3347        result.push_str(&html[..tag.close]);
3348        Self::push_adopted_attr(&mut result, adopted);
3349        result.push_str(&html[tag.close..]);
3350        result
3351    }
3352
3353    fn push_adopted_attr(out: &mut String, adopted: &str) {
3354        out.push(' ');
3355        out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
3356        out.push_str("=\"");
3357        out.push_str(adopted);
3358        out.push('"');
3359    }
3360
3361    fn adopted_attr_len(adopted: &str) -> usize {
3362        4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
3363    }
3364
3365    fn template_has_stripped_runtime_attrs(html: &str) -> bool {
3366        let trimmed = html.trim_start();
3367        let Some(tag) = html::parse_tag(trimmed) else {
3368            return false;
3369        };
3370        if tag.name != "template" || tag.closing {
3371            return false;
3372        }
3373        tag.attrs().any(|attr| {
3374            attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
3375        })
3376    }
3377
3378    fn strip_template_comments(&mut self, html: String) -> Result<String> {
3379        let mut ranges = Vec::new();
3380        let mut style_ranges = Vec::new();
3381        Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
3382
3383        for (style_start, style_end) in style_ranges {
3384            let css = &html[style_start..style_end];
3385            let (_tokens, _defs, _requirements, comments) = self
3386                .css_parser
3387                .extract_tokens_definitions_requirements_and_comments(
3388                    css,
3389                    self.options.legal_comments,
3390                )
3391                .map_err(|e| self.css_diagnostic(e))?;
3392            for comment in comments {
3393                let comment_text = &css[comment.start_byte..comment.end_byte];
3394                if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
3395                    continue;
3396                }
3397                ranges.push((
3398                    style_start + comment.start_byte,
3399                    style_start + comment.end_byte,
3400                ));
3401            }
3402        }
3403
3404        if ranges.is_empty() {
3405            return Ok(html);
3406        }
3407
3408        Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
3409    }
3410
3411    /// Collect HTML comment ranges and `<style>` raw-text ranges in one scan.
3412    fn collect_html_comment_and_style_ranges(
3413        html: &str,
3414        comment_ranges: &mut Vec<(usize, usize)>,
3415        style_ranges: &mut Vec<(usize, usize)>,
3416    ) -> Result<()> {
3417        let mut index = 0usize;
3418        while index < html.len() {
3419            let remaining = &html[index..];
3420            if remaining.starts_with("<!--") {
3421                let Some(end) = html::find_comment_close(remaining) else {
3422                    return Err(Diagnostic::error("unterminated HTML comment")
3423                        .code(codes::UNTERMINATED_HTML_COMMENT)
3424                        .at_offset(html, index)
3425                        .help("close the comment with `-->`")
3426                        .into());
3427                };
3428                comment_ranges.push((index, index + end));
3429                index += end;
3430                continue;
3431            }
3432
3433            if remaining.starts_with('<') {
3434                if let Some((style_start, style_end, close_end)) =
3435                    html::style_element_bounds(remaining)
3436                {
3437                    style_ranges.push((index + style_start, index + style_end));
3438                    index += close_end;
3439                    continue;
3440                }
3441
3442                if let Some(tag) = html::parse_tag(remaining) {
3443                    index += tag.close + 1;
3444                    continue;
3445                }
3446            }
3447
3448            let Some(ch) = remaining.chars().next() else {
3449                break;
3450            };
3451            index += ch.len_utf8();
3452        }
3453        Ok(())
3454    }
3455
3456    /// Strip attributes starting with `@`, `:`, or `?` from the opening
3457    /// `<template>` tag.
3458    ///
3459    /// Uses the same quote-aware tag scanner as the main HTML pipeline.
3460    fn strip_runtime_attrs_from_template(&mut self, html: &str) -> String {
3461        let Some(tag) = html::parse_tag(html) else {
3462            return html.to_string();
3463        };
3464
3465        let mut removals: Vec<(usize, usize)> = Vec::new();
3466        for attr in tag.attrs() {
3467            if attr.name.starts_with('@')
3468                || attr.name.starts_with(':')
3469                || attr.name.starts_with('?')
3470            {
3471                let mut start = attr.raw_range.start;
3472                while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
3473                    start -= 1;
3474                }
3475                removals.push((start, attr.raw_range.end));
3476            }
3477        }
3478
3479        if removals.is_empty() {
3480            return html.to_string();
3481        }
3482
3483        // Rebuild the string, skipping removed ranges
3484        let mut result = String::with_capacity(html.len());
3485        let mut pos = 0;
3486        for (start, end) in &removals {
3487            result.push_str(&html[pos..*start]);
3488            pos = *end;
3489        }
3490        result.push_str(&html[pos..]);
3491        result
3492    }
3493}
3494
3495#[cfg(test)]
3496mod tests {
3497    use webui_test_utils::*;
3498
3499    use super::*;
3500
3501    fn structural_matcher(value: &str) -> FragmentMatcher {
3502        signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
3503    }
3504
3505    #[test]
3506    fn test_plugin_display_names() {
3507        assert_eq!(Plugin::Fast.to_string(), "fast");
3508        assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
3509        assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
3510        assert_eq!(Plugin::WebUI.to_string(), "webui");
3511    }
3512
3513    #[test]
3514    fn test_plugin_from_str_names() {
3515        assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
3516        assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
3517        assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
3518        assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
3519    }
3520
3521    #[test]
3522    fn test_plugin_from_str_unknown_mentions_supported_names() {
3523        let err = "legacy".parse::<Plugin>().unwrap_err();
3524        assert!(
3525            err.contains("fast-v3"),
3526            "error should mention fast-v3: {err}"
3527        );
3528        assert!(
3529            err.contains("fast-v2"),
3530            "error should mention fast-v2: {err}"
3531        );
3532        assert!(err.contains("fast"), "error should mention fast: {err}");
3533    }
3534
3535    #[test]
3536    fn test_parse_signal() {
3537        let mut parser = HtmlParser::new();
3538        let html = "Hello, {{name}}!";
3539        let result = parser.parse("test.html", html);
3540
3541        assert!(result.is_ok());
3542        let fragment_records = parser.into_fragment_records();
3543
3544        assert_stream!(
3545            fragment_records,
3546            "test.html",
3547            [raw("Hello, "), signal("name"), raw("!"),]
3548        );
3549    }
3550
3551    #[test]
3552    fn test_parse_raw_signal() {
3553        let mut parser = HtmlParser::new();
3554        let html = "Hello, {{{html_content}}}!";
3555        let result = parser.parse("test.html", html);
3556
3557        assert!(result.is_ok());
3558        let fragment_records = parser.into_fragment_records();
3559
3560        assert_stream!(
3561            fragment_records,
3562            "test.html",
3563            [raw("Hello, "), signal_raw("html_content"), raw("!"),]
3564        );
3565    }
3566
3567    #[test]
3568    fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
3569        let mut parser = HtmlParser::new();
3570        let html = "<nav>{{sectionName}} &gt; {{topicName}}</nav>";
3571        let result = parser.parse("test.html", html);
3572
3573        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3574        let fragment_records = parser.into_fragment_records();
3575
3576        assert_stream!(
3577            fragment_records,
3578            "test.html",
3579            [
3580                raw("<nav>"),
3581                signal("sectionName"),
3582                raw(" &gt; "),
3583                signal("topicName"),
3584                raw("</nav>"),
3585            ]
3586        );
3587    }
3588
3589    #[test]
3590    fn test_invalid_for_each_is_template_diagnostic() {
3591        let mut parser = HtmlParser::new();
3592        let err = parser
3593            .parse(
3594                "index.html",
3595                "<div>\n  <for each=\"person inpeople\"><p>x</p></for>\n</div>",
3596            )
3597            .expect_err("invalid for-each must error");
3598        assert!(matches!(err, ParserError::Template(_)));
3599        let msg = err.to_string();
3600        assert!(msg.contains("invalid <for> each expression"), "{msg}");
3601        assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
3602        // The <for> sits on line 2, column 3 (after the two-space indent),
3603        // reported rustc-style as `--> index.html:2:3`.
3604        assert!(
3605            msg.contains("--> index.html:2:3"),
3606            "missing line:column — {msg}"
3607        );
3608        assert!(msg.contains("help:"), "{msg}");
3609    }
3610
3611    #[test]
3612    fn test_invalid_if_condition_is_template_diagnostic() {
3613        let mut parser = HtmlParser::new();
3614        let err = parser
3615            .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
3616            .expect_err("invalid condition must error");
3617        assert!(matches!(err, ParserError::Template(_)));
3618        let msg = err.to_string();
3619        assert!(msg.contains("invalid <if> condition expression"), "{msg}");
3620        assert!(
3621            msg.contains("--> index.html:1:1"),
3622            "missing line:column — {msg}"
3623        );
3624    }
3625
3626    #[test]
3627    fn test_parse_for_directive() {
3628        let mut parser = HtmlParser::new();
3629        let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
3630
3631        let result = parser.parse("test.html", html);
3632        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3633        let fragment_records = parser.into_fragment_records();
3634
3635        assert_stream!(
3636            fragment_records,
3637            "test.html",
3638            [for_loop("item", "items", "for-1"),]
3639        );
3640
3641        // Verify the sub-fragment contains our item content
3642        assert_stream!(
3643            fragment_records,
3644            "for-1",
3645            [
3646                raw("<div class=\"item\">"),
3647                signal("item.name"),
3648                raw("</div>"),
3649            ]
3650        );
3651    }
3652
3653    #[test]
3654    fn test_parse_if_directive() {
3655        let mut parser = HtmlParser::new();
3656        let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
3657
3658        let result = parser.parse("test.html", html);
3659        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3660        let fragment_records = parser.into_fragment_records();
3661
3662        assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
3663
3664        // Verify the sub-fragment contains our content
3665        assert_stream!(
3666            fragment_records,
3667            "if-1",
3668            [
3669                raw("<div>Welcome back, "),
3670                signal("username"),
3671                raw("!</div>"),
3672            ]
3673        );
3674    }
3675
3676    #[test]
3677    fn test_component_directive() {
3678        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3679        parser
3680            .component_registry
3681            .register_component(ComponentRegistration::new(
3682                "my-component",
3683                "<div>My Component</div>",
3684                Some("div { color: blue; }"),
3685                true,
3686            ))
3687            .expect("Failed to register component");
3688
3689        let result = parser.parse("test.html", "<my-component></my-component>");
3690        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3691        let records = parser.into_fragment_records();
3692
3693        assert_stream!(
3694            records,
3695            "test.html",
3696            [
3697                raw("<my-component"),
3698                structural_matcher("streaming_root:my-component"),
3699                raw(">"),
3700                component("my-component"),
3701                raw("</my-component>"),
3702            ]
3703        );
3704
3705        // Component template stream should contain the component content (no shadow DOM wrapper)
3706        let comp = &records["my-component"].fragments;
3707        assert_eq!(comp.len(), 1);
3708        assert!(
3709            matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
3710                !raw.value.contains("<template shadowrootmode") && raw.value.contains("<div>My Component</div>"))
3711        );
3712    }
3713
3714    #[test]
3715    fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
3716        // The compiler-owned streamed SSR root signal must sit between the
3717        // component's attribute run and the closing `>`, carrying the tag,
3718        // so the streaming handler can inject ` data-ws` inside the tag before
3719        // custom-element upgrade. Ordinary rendering ignores the signal.
3720        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3721        parser
3722            .component_registry
3723            .register_component(ComponentRegistration::new(
3724                "my-widget",
3725                "<div>w</div>",
3726                None,
3727                true,
3728            ))
3729            .expect("register");
3730
3731        let result = parser.parse("test.html", "<my-widget></my-widget>");
3732        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3733        let records = parser.into_fragment_records();
3734
3735        assert_stream!(
3736            records,
3737            "test.html",
3738            [
3739                raw("<my-widget"),
3740                structural_matcher("streaming_root:my-widget"),
3741                raw(">"),
3742                component("my-widget"),
3743                raw("</my-widget>"),
3744            ]
3745        );
3746    }
3747
3748    #[test]
3749    fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
3750        // Each distinct component host emits its own streaming_root signal,
3751        // including nested hosts and hosts repeated across sibling positions.
3752        use webui_protocol::web_ui_fragment::Fragment;
3753        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3754        for name in ["outer-box", "inner-pill"] {
3755            parser
3756                .component_registry
3757                .register_component(ComponentRegistration::new(
3758                    name,
3759                    "<slot></slot>",
3760                    None,
3761                    true,
3762                ))
3763                .expect("register");
3764        }
3765
3766        let result = parser.parse(
3767            "test.html",
3768            "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
3769        );
3770        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3771        let records = parser.into_fragment_records();
3772        let fragments = &records["test.html"].fragments;
3773
3774        let signals: Vec<&str> = fragments
3775            .iter()
3776            .filter_map(|f| match f.fragment.as_ref() {
3777                Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
3778                _ => None,
3779            })
3780            .collect();
3781        assert_eq!(
3782            signals,
3783            [
3784                "}}}webui:streaming_root:outer-box",
3785                "}}}webui:streaming_root:inner-pill",
3786                "}}}webui:streaming_root:inner-pill",
3787            ]
3788        );
3789
3790        // Every streaming_root signal is immediately followed by a `>` raw and
3791        // preceded by a raw ending in the host tag name (never a bare `<tag>`).
3792        for (i, frag) in fragments.iter().enumerate() {
3793            let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
3794                continue;
3795            };
3796            if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
3797                continue;
3798            }
3799            match fragments[i + 1].fragment.as_ref() {
3800                Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
3801                other => panic!("expected raw(\">\") after signal, got {other:?}"),
3802            }
3803        }
3804    }
3805
3806    #[test]
3807    fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
3808        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3809        parser
3810            .component_registry
3811            .register_component(ComponentRegistration::new(
3812                "mp-button",
3813                "<button>b</button>",
3814                None,
3815                true,
3816            ))
3817            .expect("Failed to register component");
3818
3819        // `<mp-buton>` is a same-namespace one-character typo of `mp-button`.
3820        let err = parser
3821            .parse("test.html", "<mp-buton></mp-buton>")
3822            .expect_err("a near-typo component tag should error");
3823        let ParserError::Template(diag) = err else {
3824            panic!("expected ParserError::Template, got {err:?}");
3825        };
3826        assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
3827        let help = diag
3828            .help_text()
3829            .expect("diagnostic should carry a help line");
3830        assert!(
3831            help.contains("did you mean <mp-button>?"),
3832            "expected a suggestion, got: {help}"
3833        );
3834    }
3835
3836    #[test]
3837    fn external_custom_element_in_other_namespace_passes_through() {
3838        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3839        parser
3840            .component_registry
3841            .register_component(ComponentRegistration::new(
3842                "mp-button",
3843                "<button>b</button>",
3844                None,
3845                true,
3846            ))
3847            .expect("Failed to register component");
3848
3849        // `<md-button>` is a different namespace (md- vs mp-): a genuine
3850        // third-party custom element must pass through as raw HTML, not error.
3851        let result = parser.parse("test.html", "<md-button></md-button>");
3852        assert!(
3853            result.is_ok(),
3854            "external custom element should pass through, got: {:?}",
3855            result.err()
3856        );
3857    }
3858
3859    #[test]
3860    fn for_missing_each_suggests_typoed_attribute() {
3861        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3862        // `eahc` is a transposition of the required `each` attribute.
3863        let err = parser
3864            .parse("test.html", "<for eahc=\"todo in todos\"></for>")
3865            .expect_err("a <for> without `each` should error");
3866        let ParserError::Template(diag) = err else {
3867            panic!("expected ParserError::Template, got {err:?}");
3868        };
3869        assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
3870        let help = diag
3871            .help_text()
3872            .expect("diagnostic should carry a help line");
3873        assert!(
3874            help.contains("found `eahc`") && help.contains("did you mean"),
3875            "expected an attribute-typo suggestion, got: {help}"
3876        );
3877    }
3878
3879    #[test]
3880    fn parent_diagnostic_owner_survives_nested_component_parse() {
3881        // Regression: a component parse recurses through `self.parse(child, …)`,
3882        // which used to leave `current_fragment_id` pointing at the child. A
3883        // later error in the PARENT template must still be attributed to the
3884        // parent (here `index.html`), not the nested component.
3885        let mut parser = HtmlParser::with_options(DomStrategy::Light);
3886        parser
3887            .component_registry
3888            .register_component(ComponentRegistration::new(
3889                "my-card",
3890                "<div>card</div>",
3891                None,
3892                true,
3893            ))
3894            .expect("register");
3895
3896        // A registered component first, then a broken <for> in the same template.
3897        let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
3898        let err = parser
3899            .parse("index.html", html)
3900            .expect_err("the malformed <for> should error");
3901        let ParserError::Template(diag) = err else {
3902            panic!("expected ParserError::Template, got {err:?}");
3903        };
3904        assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
3905        assert_eq!(
3906            diag.component_name(),
3907            Some("index.html"),
3908            "parent diagnostic must be owned by index.html, not the nested component"
3909        );
3910    }
3911
3912    #[test]
3913    fn test_component_directive_with_slots() {
3914        let mut parser = HtmlParser::new();
3915        parser
3916            .component_registry
3917            .register_component(ComponentRegistration::new(
3918                "my-component",
3919                "<div>My Component</div>",
3920                Some("div { color: blue; }"),
3921                true,
3922            ))
3923            .expect("Failed to register component");
3924
3925        let result = parser.parse(
3926            "test.html",
3927            "Hello<my-component><p>World</p></my-component>",
3928        );
3929        assert!(result.is_ok(), "Parse error: {:?}", result.err());
3930        let records = parser.into_fragment_records();
3931        let fragments = &records["test.html"].fragments;
3932
3933        // Entry: raw(Hello<my-component>) + component + raw(<p>World</p></my-component>)
3934        assert!(fragments.len() >= 3);
3935        // First fragment should contain "Hello" and "<my-component" (the opening
3936        // tag is split by the compiler-owned streaming_root signal before `>`).
3937        assert!(
3938            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
3939        );
3940        // Should have component fragment
3941        assert!(fragments.iter().any(|f| matches!(
3942            f.fragment.as_ref(),
3943            Some(Fragment::Component(c)) if c.fragment_id == "my-component"
3944        )));
3945        // Should end with closing tag
3946        let last = fragments.last().unwrap();
3947        assert!(
3948            matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
3949        );
3950    }
3951
3952    #[test]
3953    fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
3954        // Reproduces commerce app: mp-app has a shadow DOM template containing
3955        // child components (mp-navbar, mp-cart-panel, mp-footer) plus an <outlet>.
3956        // The parser must emit Fragment::Component entries for ALL child components
3957        // so the inventory walk finds them.
3958        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
3959
3960        parser
3961            .component_registry
3962            .register_component(ComponentRegistration::new(
3963                "app-shell",
3964                r#"<template shadowrootmode="open">
3965                  <my-navbar></my-navbar>
3966                  <main><outlet /></main>
3967                  <cart-panel></cart-panel>
3968                  <my-footer></my-footer>
3969                </template>"#,
3970                Some(":host{display:flex}"),
3971                true,
3972            ))
3973            .expect("register app-shell");
3974        parser
3975            .component_registry
3976            .register_component(ComponentRegistration::new(
3977                "my-navbar",
3978                "<nav>Nav</nav>",
3979                Some("nav{color:red}"),
3980                true,
3981            ))
3982            .expect("register my-navbar");
3983        parser
3984            .component_registry
3985            .register_component(ComponentRegistration::new(
3986                "cart-panel",
3987                "<aside>Cart</aside>",
3988                Some("aside{color:green}"),
3989                true,
3990            ))
3991            .expect("register cart-panel");
3992        parser
3993            .component_registry
3994            .register_component(ComponentRegistration::new(
3995                "my-footer",
3996                "<footer>Footer</footer>",
3997                Some("footer{color:blue}"),
3998                true,
3999            ))
4000            .expect("register my-footer");
4001
4002        parser
4003            .parse("index.html", "<app-shell></app-shell>")
4004            .expect("parse index.html");
4005        let records = parser.into_fragment_records();
4006
4007        // app-shell's fragment list must contain Component entries for all children
4008        let app_frags = &records["app-shell"].fragments;
4009        let component_ids: Vec<&str> = app_frags
4010            .iter()
4011            .filter_map(|f| match f.fragment.as_ref() {
4012                Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
4013                _ => None,
4014            })
4015            .collect();
4016
4017        assert!(
4018            component_ids.contains(&"my-navbar"),
4019            "app-shell fragments should contain my-navbar: {component_ids:?}"
4020        );
4021        assert!(
4022            component_ids.contains(&"cart-panel"),
4023            "app-shell fragments should contain cart-panel: {component_ids:?}"
4024        );
4025        assert!(
4026            component_ids.contains(&"my-footer"),
4027            "app-shell fragments should contain my-footer: {component_ids:?}"
4028        );
4029    }
4030
4031    // ── Component template wrapping tests ────────────────────────────
4032
4033    #[test]
4034    fn test_component_no_double_wrap_template() {
4035        // Developer-authored <template foo="bar"> must be preserved verbatim
4036        // in --dom=light. The framework only strips runtime-only attrs;
4037        // every other attribute is the developer's responsibility.
4038        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4039        parser
4040            .component_registry
4041            .register_component(ComponentRegistration::new(
4042                "custom-element",
4043                r#"<template foo="bar"><slot></slot></template>"#,
4044                None,
4045                true,
4046            ))
4047            .expect("register");
4048        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4049        assert!(result.is_ok());
4050        let records = parser.into_fragment_records();
4051
4052        assert_fragments!(
4053            records["index.html"].fragments,
4054            [
4055                raw("<custom-element"),
4056                structural_matcher("streaming_root:custom-element"),
4057                raw(">"),
4058                component("custom-element"),
4059                raw("Hello</custom-element>"),
4060            ]
4061        );
4062
4063        assert_stream!(
4064            records,
4065            "custom-element",
4066            [raw(r#"<template foo="bar"><slot></slot></template>"#),]
4067        );
4068    }
4069
4070    #[test]
4071    fn test_component_styled_no_double_wrap() {
4072        // --dom=light with a developer-supplied <template> wrapper preserves
4073        // the wrapper verbatim. CSS is the default `Link` strategy which
4074        // injects only in shadow DOM, so in light mode there is no CSS
4075        // snippet to splice into the wrapper.
4076        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4077        parser
4078            .component_registry
4079            .register_component(ComponentRegistration::new(
4080                "custom-element",
4081                r#"<template foo="bar"><slot></slot></template>"#,
4082                Some("div { color: red; }"),
4083                true,
4084            ))
4085            .expect("register");
4086        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4087        assert!(result.is_ok());
4088        let records = parser.into_fragment_records();
4089
4090        assert_stream!(
4091            records,
4092            "custom-element",
4093            [raw(r#"<template foo="bar"><slot></slot></template>"#),]
4094        );
4095    }
4096
4097    #[test]
4098    fn test_component_strip_runtime_attrs() {
4099        // Runtime-only attributes (`@event`, `:bind`, `?cond`) are stripped
4100        // from the opening <template> tag, but the wrapper itself is
4101        // preserved. After stripping in this case the wrapper becomes
4102        // `<template>` with no attributes.
4103        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4104        parser
4105            .component_registry
4106            .register_component(ComponentRegistration::new(
4107                "custom-element",
4108                r#"<template @click={foo} :bar="baz" ?bool="true"><slot></slot></template>"#,
4109                None,
4110                true,
4111            ))
4112            .expect("register");
4113        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4114        assert!(result.is_ok());
4115        let records = parser.into_fragment_records();
4116
4117        assert_stream!(
4118            records,
4119            "custom-element",
4120            [raw("<template><slot></slot></template>"),]
4121        );
4122    }
4123
4124    #[test]
4125    fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
4126        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4127        parser
4128            .component_registry
4129            .register_component(ComponentRegistration::new(
4130                "custom-element",
4131                r#"<template data-note="@click={foo}" @click={foo}><slot></slot></template>"#,
4132                None,
4133                true,
4134            ))
4135            .expect("register");
4136        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4137        assert!(result.is_ok());
4138        let records = parser.into_fragment_records();
4139
4140        assert_stream!(
4141            records,
4142            "custom-element",
4143            [raw(
4144                r#"<template data-note="@click={foo}"><slot></slot></template>"#
4145            ),]
4146        );
4147    }
4148
4149    #[test]
4150    fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
4151        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4152        parser
4153            .component_registry
4154            .register_component(ComponentRegistration::new(
4155                "custom-element",
4156                r#"<template @click={foo} @click={bar}><slot></slot></template>"#,
4157                None,
4158                true,
4159            ))
4160            .expect("register");
4161        let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
4162        assert!(result.is_ok());
4163        let records = parser.into_fragment_records();
4164
4165        assert_stream!(
4166            records,
4167            "custom-element",
4168            [raw("<template><slot></slot></template>"),]
4169        );
4170    }
4171
4172    #[test]
4173    fn test_component_with_slots_and_attrs() {
4174        let mut parser = HtmlParser::new();
4175        parser
4176            .component_registry
4177            .register_component(ComponentRegistration::new(
4178                "custom-element",
4179                "<slot></slot>",
4180                None,
4181                true,
4182            ))
4183            .expect("register");
4184        let result = parser.parse(
4185            "index.html",
4186            r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
4187        );
4188        assert!(result.is_ok());
4189        let records = parser.into_fragment_records();
4190        assert_fragments!(
4191            records["index.html"].fragments,
4192            [
4193                raw("<custom-element"),
4194                attr_raw_start("appearance", "subtle"),
4195                structural_matcher("streaming_root:custom-element"),
4196                raw(">"),
4197                component("custom-element"),
4198                raw("Hello World</custom-element>"),
4199            ]
4200        );
4201    }
4202
4203    #[test]
4204    fn test_component_legacy_no_styles() {
4205        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4206        parser
4207            .component_registry
4208            .register_component(ComponentRegistration::new(
4209                "custom-element",
4210                "<div>Custom Element</div>",
4211                None,
4212                true,
4213            ))
4214            .expect("register");
4215        let result = parser.parse("index.html", "<custom-element></custom-element>");
4216        assert!(result.is_ok());
4217        let records = parser.into_fragment_records();
4218
4219        assert_fragments!(
4220            records["index.html"].fragments,
4221            [
4222                raw("<custom-element"),
4223                structural_matcher("streaming_root:custom-element"),
4224                raw(">"),
4225                component("custom-element"),
4226                raw("</custom-element>"),
4227            ]
4228        );
4229
4230        assert_stream!(
4231            records,
4232            "custom-element",
4233            [raw("<div>Custom Element</div>"),]
4234        );
4235    }
4236
4237    #[test]
4238    fn test_component_self_closing() {
4239        let mut parser = HtmlParser::new();
4240        parser
4241            .component_registry
4242            .register_component(ComponentRegistration::new(
4243                "custom-widget",
4244                "<div>Widget Content</div>",
4245                None,
4246                true,
4247            ))
4248            .expect("register");
4249        let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
4250        assert!(result.is_ok());
4251        let records = parser.into_fragment_records();
4252
4253        assert_fragments!(
4254            records["index.html"].fragments,
4255            [
4256                raw("<custom-widget"),
4257                attr_start("config", "settings"),
4258                structural_matcher("streaming_root:custom-widget"),
4259                raw("/>"),
4260                component("custom-widget"),
4261            ]
4262        );
4263    }
4264
4265    #[test]
4266    fn test_component_nested_self_closing_in_slot() {
4267        let mut parser = HtmlParser::with_options(DomStrategy::Light);
4268        parser
4269            .component_registry
4270            .register_component(ComponentRegistration::new(
4271                "custom-icon",
4272                "<svg><slot></slot></svg>",
4273                None,
4274                true,
4275            ))
4276            .expect("register");
4277        let result = parser.parse(
4278            "index.html",
4279            r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
4280        );
4281        assert!(result.is_ok());
4282        let records = parser.into_fragment_records();
4283
4284        assert_fragments!(
4285            records["index.html"].fragments,
4286            [
4287                raw("<custom-icon"),
4288                structural_matcher("streaming_root:custom-icon"),
4289                raw(">"),
4290                component("custom-icon"),
4291                raw("<use"),
4292                attr_template("href", "attr-1"),
4293                raw("/></custom-icon>"),
4294            ]
4295        );
4296
4297        assert_stream!(records, "custom-icon", [raw("<svg><slot></slot></svg>"),]);
4298    }
4299
4300    #[test]
4301    fn test_component_leading_boolean_attr_start() {
4302        let mut parser = HtmlParser::new();
4303        parser
4304            .component_registry
4305            .register_component(ComponentRegistration::new(
4306                "custom-element",
4307                "<slot></slot>",
4308                None,
4309                true,
4310            ))
4311            .expect("register");
4312        let result = parser.parse(
4313            "index.html",
4314            r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
4315        );
4316        assert!(result.is_ok());
4317        let records = parser.into_fragment_records();
4318
4319        assert_fragments!(
4320            records["index.html"].fragments,
4321            [
4322                raw("<custom-element"),
4323                // First dynamic attr: boolean with attrStart
4324                bool_attr_start("disabled", "isDisabled"),
4325                // Static attr after dynamic: rawValue
4326                attr_raw("title", "Hello"),
4327                structural_matcher("streaming_root:custom-element"),
4328                raw(">"),
4329                component("custom-element"),
4330                raw("</custom-element>"),
4331            ]
4332        );
4333    }
4334
4335    #[test]
4336    fn test_component_boolean_predicate_preserves_condition_tree() {
4337        let mut parser = HtmlParser::new();
4338        parser
4339            .component_registry
4340            .register_component(ComponentRegistration::new(
4341                "custom-element",
4342                "<slot></slot>",
4343                None,
4344                true,
4345            ))
4346            .expect("register");
4347        let result = parser.parse(
4348            "index.html",
4349            r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
4350        );
4351        assert!(result.is_ok());
4352
4353        let records = parser.into_fragment_records();
4354        let fragments = &records["index.html"].fragments;
4355        match fragments
4356            .get(1)
4357            .and_then(|fragment| fragment.fragment.as_ref())
4358        {
4359            Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
4360                assert_eq!(attr.name, "disabled");
4361                assert!(attr.attr_start);
4362                match attr
4363                    .condition_tree
4364                    .as_ref()
4365                    .and_then(|condition| condition.expr.as_ref())
4366                {
4367                    Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
4368                        assert_eq!(pred.left, "page");
4369                        assert_eq!(pred.operator, 3);
4370                        assert_eq!(pred.right, "'dashboard'");
4371                    }
4372                    other => panic!("expected predicate condition tree, got {:?}", other),
4373                }
4374            }
4375            other => panic!("expected attribute fragment, got {:?}", other),
4376        }
4377    }
4378
4379    #[test]
4380    fn test_component_meta_link_tags() {
4381        let (fragments, _) = parse_and_get_fragments(
4382            r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
4383        );
4384        assert!(fragments.len() >= 7);
4385        assert!(
4386            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
4387        );
4388        assert!(
4389            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
4390                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
4391        );
4392        assert!(
4393            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
4394        );
4395        assert!(
4396            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
4397        );
4398        assert!(
4399            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
4400        );
4401        assert!(
4402            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
4403                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
4404        );
4405        assert!(
4406            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
4407        );
4408    }
4409
4410    #[test]
4411    fn test_nested_directives() {
4412        let mut parser = HtmlParser::new();
4413        let html = r#"<for each="category in categories">
4414            <if condition="category.hasItems">
4415                <for each="item in category.items">
4416                   {{item.title}}
4417                </for>
4418            </if>
4419        </for>"#;
4420
4421        let result = parser.parse("test.html", html);
4422
4423        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4424        let fragment_records = parser.into_fragment_records();
4425
4426        assert_stream!(
4427            fragment_records,
4428            "test.html",
4429            [for_loop("category", "categories", "for-1"),]
4430        );
4431
4432        assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
4433
4434        assert_stream!(
4435            fragment_records,
4436            "if-1",
4437            [for_loop("item", "category.items", "for-2"),]
4438        );
4439
4440        assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
4441    }
4442
4443    #[test]
4444    fn test_complex_directives() {
4445        let mut parser = HtmlParser::new();
4446        let html = r#"<for each="category in categories">
4447            <div class="category">
4448                <h2>{{category.name}}</h2>
4449                <if condition="category.hasItems">
4450                    <ul>
4451                        <for each="item in category.items">
4452                            <li>{{item.title}}</li>
4453                        </for>
4454                    </ul>
4455                </if>
4456            </div>
4457        </for>"#;
4458
4459        let result = parser.parse("test.html", html);
4460
4461        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4462        let fragment_records = parser.into_fragment_records();
4463
4464        assert_stream!(
4465            fragment_records,
4466            "test.html",
4467            [for_loop("category", "categories", "for-1"),]
4468        );
4469
4470        // Verify for fragments contains the category.name signal
4471        assert_stream!(
4472            fragment_records,
4473            "for-1",
4474            [
4475                raw("<div class=\"category\"><h2>"),
4476                signal("category.name"),
4477                raw("</h2>"),
4478                if_cond("if-1"),
4479                raw("</div>"),
4480            ]
4481        );
4482
4483        // Verify nested if condition.
4484        assert_stream!(
4485            fragment_records,
4486            "if-1",
4487            [
4488                raw("<ul>"),
4489                for_loop("item", "category.items", "for-2"),
4490                raw("</ul>"),
4491            ]
4492        );
4493
4494        // Verify nested for each.
4495        assert_stream!(
4496            fragment_records,
4497            "for-2",
4498            [raw("<li>"), signal("item.title"), raw("</li>"),]
4499        );
4500    }
4501
4502    // ── Attribute fragment tests ─────────────────────────────────────────
4503
4504    /// Helper to parse HTML and return the fragments for the entry stream.
4505    fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
4506        let mut parser = HtmlParser::new();
4507        let result = parser.parse("index.html", html);
4508        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4509        let records = parser.into_fragment_records();
4510        let fragments = records
4511            .get("index.html")
4512            .expect("Failed to get index.html fragment")
4513            .fragments
4514            .clone();
4515        (fragments, records)
4516    }
4517
4518    /// Helper to parse HTML with a pre-registered component.
4519    fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
4520        let mut parser = HtmlParser::new();
4521        parser
4522            .component_registry
4523            .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
4524            .expect("register");
4525        let result = parser.parse("index.html", html);
4526        assert!(result.is_ok(), "Parse error: {:?}", result.err());
4527        let records = parser.into_fragment_records();
4528        let fragments = records
4529            .get("index.html")
4530            .expect("Failed to get index.html fragment")
4531            .fragments
4532            .clone();
4533        (fragments, records)
4534    }
4535
4536    #[test]
4537    fn test_attribute_handlebars_in_href() {
4538        // Port of: 'should process handlebars from attributes as signals'
4539        let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
4540        assert_fragments!(
4541            fragments,
4542            [
4543                raw("<a"),
4544                attr("href", "url"),
4545                raw(">"),
4546                signal("name"),
4547                raw("</a>"),
4548            ]
4549        );
4550    }
4551
4552    #[test]
4553    fn test_attribute_boolean_with_handlebars() {
4554        // Port of: 'should process boolean attribute with handlebars expression'
4555        let (fragments, _) =
4556            parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
4557        assert_fragments!(
4558            fragments,
4559            [
4560                raw("<button"),
4561                bool_attr("disabled", "isDisabled"),
4562                raw(">Click</button>"),
4563            ]
4564        );
4565    }
4566
4567    #[test]
4568    fn test_attribute_multiple_boolean() {
4569        // Port of: 'should process multiple boolean attributes'
4570        // <input ?checked={{isChecked}} ?disabled={{isDisabled}} />
4571        let (fragments, _) =
4572            parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
4573
4574        assert_fragments!(
4575            fragments,
4576            [
4577                raw("<input"),
4578                bool_attr("checked", "isChecked"),
4579                bool_attr("disabled", "isDisabled"),
4580                raw("/>"),
4581            ]
4582        );
4583    }
4584
4585    #[test]
4586    fn test_attribute_boolean_and_regular_together() {
4587        // Port of: 'should process a boolean attribute and a regular attribute together'
4588        // <button ?disabled="{{isDisabled}}" type="button">Hi</button>
4589        let (fragments, _) = parse_and_get_fragments(
4590            r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
4591        );
4592
4593        assert_fragments!(
4594            fragments,
4595            [
4596                raw("<button"),
4597                bool_attr("disabled", "isDisabled"),
4598                raw(" type=\"button\">Hi</button>"),
4599            ]
4600        );
4601    }
4602
4603    #[test]
4604    fn test_attribute_boolean_sandwiched() {
4605        // Port of: 'should process a boolean attribute sandwiched between regular attributes'
4606        // <button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>
4607        let (fragments, _) = parse_and_get_fragments(
4608            r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
4609        );
4610
4611        assert_fragments!(
4612            fragments,
4613            [
4614                raw("<button"),
4615                attr("version", "edition"),
4616                bool_attr("disabled", "isDisabled"),
4617                raw(" type=\"button\">Hi</button>"),
4618            ]
4619        );
4620    }
4621
4622    #[test]
4623    fn test_attribute_boolean_ending() {
4624        // Port of: 'should process html ending with boolean attribute correctly'
4625        // <button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>
4626        let (fragments, _) = parse_and_get_fragments(
4627            r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
4628        );
4629
4630        assert_fragments!(
4631            fragments,
4632            [
4633                raw("<button"),
4634                attr("version", "edition"),
4635                bool_attr("disabled", "isDisabled"),
4636                raw(">Hi</button>"),
4637            ]
4638        );
4639    }
4640
4641    #[test]
4642    fn test_attribute_boolean_dotted_path() {
4643        // Port of: 'should process boolean attribute with dotted path'
4644        // <div ?checked={{layout.isPinned}}>Content</div>
4645        let (fragments, _) =
4646            parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
4647
4648        assert_fragments!(
4649            fragments,
4650            [
4651                raw("<div"),
4652                bool_attr("checked", "layout.isPinned"),
4653                raw(">Content</div>"),
4654            ]
4655        );
4656    }
4657
4658    #[test]
4659    fn test_attribute_colon_prefixed_complex() {
4660        // Port of: 'should process colon-prefixed attribute with handlebars'
4661        // <my-component :config="{{settings}}"></my-component>
4662        let (fragments, _) = parse_with_component(
4663            "my-component",
4664            r#"<my-component :config="{{settings}}"></my-component>"#,
4665        );
4666
4667        assert_fragments!(
4668            fragments,
4669            [
4670                raw("<my-component"),
4671                attr_complex_start(":config", "settings"),
4672                structural_matcher("streaming_root:my-component"),
4673                raw(">"),
4674                component("my-component"),
4675                raw("</my-component>"),
4676            ]
4677        );
4678    }
4679
4680    #[test]
4681    fn test_attribute_multiple_colon_prefixed() {
4682        // Port of: 'should process multiple colon-prefixed complex attributes'
4683        // <my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>
4684        let (fragments, _) = parse_with_component(
4685            "my-component",
4686            r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
4687        );
4688
4689        assert_fragments!(
4690            fragments,
4691            [
4692                raw("<my-component"),
4693                attr_complex_start(":prop1", "val1"),
4694                attr_complex(":prop2", "val2"),
4695                structural_matcher("streaming_root:my-component"),
4696                raw(">"),
4697                component("my-component"),
4698                raw("</my-component>"),
4699            ]
4700        );
4701    }
4702
4703    #[test]
4704    fn test_blocked_complex_property_innerhtml() {
4705        let mut parser = HtmlParser::new();
4706        let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
4707        assert!(
4708            result.is_err(),
4709            "Expected error for :innerHTML on native element"
4710        );
4711        let err = result.unwrap_err().to_string();
4712        assert!(
4713            err.contains("only allowed on custom elements"),
4714            "Error: {err}"
4715        );
4716    }
4717
4718    #[test]
4719    fn test_blocked_complex_on_native_element() {
4720        let mut parser = HtmlParser::new();
4721        let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
4722        assert!(
4723            result.is_err(),
4724            "Expected error for :data on native element"
4725        );
4726        let err = result.unwrap_err().to_string();
4727        assert!(
4728            err.contains("only allowed on custom elements"),
4729            "Error: {err}"
4730        );
4731    }
4732
4733    #[test]
4734    fn test_blocked_complex_property_on_component() {
4735        let mut parser = HtmlParser::new();
4736        parser
4737            .component_registry
4738            .register_component(ComponentRegistration::new(
4739                "my-widget",
4740                "<div></div>",
4741                None,
4742                true,
4743            ))
4744            .expect("register");
4745        let result = parser.parse(
4746            "index.html",
4747            r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
4748        );
4749        assert!(
4750            result.is_err(),
4751            "Expected error for :innerHTML on component"
4752        );
4753        let err = result.unwrap_err().to_string();
4754        assert!(err.contains("HTML injection"), "Error: {err}");
4755    }
4756
4757    #[test]
4758    fn test_allowed_complex_property() {
4759        // :config on a component should still work
4760        let (fragments, _) = parse_with_component(
4761            "my-component",
4762            r#"<my-component :config="{{settings}}"></my-component>"#,
4763        );
4764        assert_fragments!(
4765            fragments,
4766            [
4767                raw("<my-component"),
4768                attr_complex_start(":config", "settings"),
4769                structural_matcher("streaming_root:my-component"),
4770                raw(">"),
4771                component("my-component"),
4772                raw("</my-component>"),
4773            ]
4774        );
4775    }
4776
4777    #[test]
4778    fn test_attribute_mixed_normal_boolean_colon() {
4779        // Port of: 'should process mixed normal, boolean, and colon-prefixed attributes'
4780        // <my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>
4781        let (fragments, _) = parse_with_component(
4782            "my-component",
4783            r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
4784        );
4785
4786        assert_fragments!(
4787            fragments,
4788            [
4789                raw("<my-component"),
4790                attr_raw_start("id", "comp"),
4791                attr_complex(":config", "settings"),
4792                bool_attr("enabled", "isEnabled"),
4793                structural_matcher("streaming_root:my-component"),
4794                raw(">"),
4795                component("my-component"),
4796                raw("</my-component>"),
4797            ]
4798        );
4799    }
4800
4801    #[test]
4802    fn test_attribute_reject_boolean_without_handlebars() {
4803        // Port of: 'should reject boolean attribute without handlebars'
4804        // <button ?checked="name"></button>
4805        let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
4806
4807        // Boolean attribute is silently dropped
4808        assert_fragments!(fragments, [raw("<button></button>"),]);
4809    }
4810
4811    #[test]
4812    fn test_attribute_reject_boolean_with_partial_handlebars() {
4813        // Port of: 'should reject boolean attribute with partial handlebars'
4814        // <button ?checked="Hello {{name}}"></button>
4815        let (fragments, _) =
4816            parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
4817
4818        // Boolean attribute is silently dropped
4819        assert_fragments!(fragments, [raw("<button></button>"),]);
4820    }
4821
4822    #[test]
4823    fn test_attribute_reject_boolean_with_plain_value() {
4824        // Port of: 'should reject boolean attribute with plain value'
4825        // <button ?disabled="true">Click</button>
4826        let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
4827
4828        // Boolean attribute is silently dropped
4829        assert_fragments!(fragments, [raw("<button>Click</button>"),]);
4830    }
4831
4832    #[test]
4833    fn test_attribute_boolean_predicate_equal() {
4834        // Boolean attribute with == predicate expression
4835        let (fragments, _) =
4836            parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
4837        assert_fragments!(
4838            fragments,
4839            [
4840                raw("<div"),
4841                bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
4842                raw(">X</div>"),
4843            ]
4844        );
4845    }
4846
4847    #[test]
4848    fn test_attribute_boolean_predicate_greater_than() {
4849        // Boolean attribute with > predicate expression
4850        let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
4851        assert_fragments!(
4852            fragments,
4853            [
4854                raw("<span"),
4855                bool_attr_predicate("hidden", "num", 1, "9"),
4856                raw(">X</span>"),
4857            ]
4858        );
4859    }
4860
4861    #[test]
4862    fn test_attribute_boolean_predicate_not_equal() {
4863        // Boolean attribute with != predicate expression
4864        let (fragments, _) =
4865            parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
4866        assert_fragments!(
4867            fragments,
4868            [
4869                raw("<a"),
4870                bool_attr_predicate("data-active", "status", 4, "'inactive'"),
4871                raw(">X</a>"),
4872            ]
4873        );
4874    }
4875
4876    #[test]
4877    fn test_attribute_boolean_negation() {
4878        // Boolean attribute with negated expression
4879        let (fragments, _) =
4880            parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
4881        assert_fragments!(
4882            fragments,
4883            [
4884                raw("<button"),
4885                bool_attr_not("disabled", "isReady"),
4886                raw(">X</button>"),
4887            ]
4888        );
4889    }
4890
4891    #[test]
4892    fn test_attribute_mixed_static_dynamic() {
4893        // Port of: 'should process mixed attributes correctly'
4894        // <textarea value="hello {{world}}">Hi</textarea>
4895        let (fragments, records) =
4896            parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
4897
4898        assert_fragments!(
4899            fragments,
4900            [
4901                raw("<textarea"),
4902                attr_template("value", "attr-1"),
4903                raw(">Hi</textarea>"),
4904            ]
4905        );
4906
4907        // Verify the template sub-stream
4908        assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
4909    }
4910
4911    // ── Body signal tests ─────────────────────────────────────────────
4912
4913    #[test]
4914    fn test_body_signals() {
4915        let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
4916        assert_fragments!(
4917            fragments,
4918            [
4919                raw("<body>"),
4920                structural_matcher("body_start"),
4921                raw("<app-shell></app-shell>"),
4922                structural_matcher("body_end"),
4923                raw("</body>"),
4924            ]
4925        );
4926    }
4927
4928    // ── Empty for handling tests ──────────────────────────────────────
4929
4930    #[test]
4931    fn test_empty_for_produces_nothing() {
4932        let (fragments, records) =
4933            parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
4934        assert_fragments!(fragments, [raw("<div></div>"),]);
4935        assert!(!records.contains_key("for-1"));
4936    }
4937
4938    // ── Self-closing / void element tests ─────────────────────────────
4939
4940    #[test]
4941    fn test_self_closing_svg_path() {
4942        let (fragments, _) =
4943            parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
4944        assert_fragments!(
4945            fragments,
4946            [raw(
4947                r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
4948            ),]
4949        );
4950    }
4951
4952    #[test]
4953    fn test_html5_void_elements() {
4954        let (fragments, _) = parse_and_get_fragments(
4955            r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
4956        );
4957        assert_fragments!(
4958            fragments,
4959            [raw(
4960                r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
4961            ),]
4962        );
4963    }
4964
4965    #[test]
4966    fn test_self_closing_with_dynamic_attributes() {
4967        let (fragments, _) =
4968            parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
4969        assert_fragments!(
4970            fragments,
4971            [
4972                raw("<img"),
4973                attr("src", "imageUrl"),
4974                attr("alt", "imageAlt"),
4975                raw("/>"),
4976            ]
4977        );
4978    }
4979
4980    #[test]
4981    fn test_self_closing_with_boolean_attributes() {
4982        let (fragments, _) = parse_and_get_fragments(
4983            r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
4984        );
4985        assert_fragments!(
4986            fragments,
4987            [
4988                raw("<input type=\"checkbox\""),
4989                bool_attr("checked", "isSelected"),
4990                bool_attr("disabled", "isDisabled"),
4991                raw("/>"),
4992            ]
4993        );
4994    }
4995
4996    #[test]
4997    fn test_multiple_self_closing_in_sequence() {
4998        let (fragments, _) =
4999            parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
5000        assert_fragments!(
5001            fragments,
5002            [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
5003        );
5004    }
5005
5006    #[test]
5007    fn test_self_closing_with_mixed_content() {
5008        let (fragments, _) =
5009            parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
5010        assert_fragments!(
5011            fragments,
5012            [
5013                raw("<div>Text before<img"),
5014                attr("src", "url"),
5015                raw("/>Text after</div>"),
5016            ]
5017        );
5018    }
5019
5020    #[test]
5021    fn test_self_closing_svg_elements() {
5022        let (fragments, _) = parse_and_get_fragments(
5023            r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
5024        );
5025        assert_fragments!(
5026            fragments,
5027            [
5028                raw("<svg><circle"),
5029                attr("cx", "x"),
5030                attr("cy", "y"),
5031                raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
5032            ]
5033        );
5034    }
5035
5036    #[test]
5037    fn test_self_closing_inside_for_loop() {
5038        let (fragments, records) = parse_and_get_fragments(
5039            r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
5040        );
5041        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
5042        assert_stream!(
5043            records,
5044            "for-1",
5045            [raw("<img"), attr("src", "item.url"), raw("/>"),]
5046        );
5047    }
5048
5049    #[test]
5050    fn test_self_closing_whitespace_variations() {
5051        let (fragments, _) =
5052            parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
5053        assert_fragments!(
5054            fragments,
5055            [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
5056        );
5057    }
5058
5059    #[test]
5060    fn test_deeply_nested_self_closing() {
5061        let (fragments, _) = parse_and_get_fragments(
5062            r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
5063        );
5064        assert_fragments!(
5065            fragments,
5066            [raw(
5067                r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
5068            ),]
5069        );
5070    }
5071
5072    #[test]
5073    fn test_self_closing_vs_empty_regular_tags() {
5074        let (fragments, _) =
5075            parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
5076        assert_fragments!(
5077            fragments,
5078            [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
5079        );
5080    }
5081
5082    // ── Feature 1: Custom template attribute on <for> ────────────────────
5083
5084    #[test]
5085    fn test_for_custom_template_attribute() {
5086        // Port of: 'should process transient node for with template'
5087        let (fragments, records) = parse_and_get_fragments(
5088            r#"<for each="item in items" template="static"><span>Item</span></for>"#,
5089        );
5090        assert_fragments!(fragments, [for_loop("item", "items", "static"),]);
5091        assert_stream!(records, "static", [raw("<span>Item</span>"),]);
5092    }
5093
5094    #[test]
5095    fn test_for_recursive_template() {
5096        // Port of: 'should process recursive transient nodes'
5097        let mut parser = HtmlParser::new();
5098        let html = r#"<for template="static" each="outerItem in outerItems"><div><span>{{outerItem.name}}</span><for template="static" each="innerItem in innerItems" /></div></for>"#;
5099        let result = parser.parse("index.html", html);
5100        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5101        let records = parser.into_fragment_records();
5102
5103        assert_fragments!(
5104            records["index.html"].fragments,
5105            [for_loop("outerItem", "outerItems", "static"),]
5106        );
5107
5108        assert_stream!(
5109            records,
5110            "static",
5111            [
5112                raw("<div><span>"),
5113                signal("outerItem.name"),
5114                raw("</span>"),
5115                for_loop("innerItem", "innerItems", "static"),
5116                raw("</div>"),
5117            ]
5118        );
5119    }
5120
5121    // ── Feature 2: <if> / <for> with multiple children ──────────────────
5122
5123    #[test]
5124    fn test_if_multiple_children() {
5125        // Port of: 'should handle <if> with multiple children'
5126        let (fragments, records) =
5127            parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
5128        assert_fragments!(fragments, [if_cond("if-1"),]);
5129        assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
5130    }
5131
5132    #[test]
5133    fn test_for_multiple_children() {
5134        // Port of: 'should handle <for> with multiple children'
5135        let (fragments, records) =
5136            parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
5137        assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
5138        assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
5139    }
5140
5141    // ── Feature 3: Handlebars at beginning/end of text ──────────────────
5142
5143    #[test]
5144    fn test_handlebars_at_beginning() {
5145        // Port of: 'should process handlebars from text at beginning'
5146        let (fragments, _) = parse_and_get_fragments("{{first}}");
5147        assert_fragments!(fragments, [signal("first"),]);
5148    }
5149
5150    #[test]
5151    fn test_handlebars_at_beginning_and_raw() {
5152        // Port of: 'should process handlebars from text at beginning and raw'
5153        let (fragments, _) = parse_and_get_fragments("{{first}}test");
5154        assert_fragments!(fragments, [signal("first"), raw("test"),]);
5155    }
5156
5157    #[test]
5158    fn test_handlebars_raw_and_end() {
5159        // Port of: 'should process handlebars from text at raw and end'
5160        let (fragments, _) = parse_and_get_fragments("test{{first}}");
5161        assert_fragments!(fragments, [raw("test"), signal("first"),]);
5162    }
5163
5164    // ── Feature 4: Handlebars edge cases ────────────────────────────────
5165
5166    #[test]
5167    fn test_handlebars_invalid_triple_open() {
5168        // Port of: 'should not process handlebars when invalid'
5169        let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
5170        assert_fragments!(fragments, [raw("{{{invalid}}"),]);
5171    }
5172
5173    #[test]
5174    fn test_handlebars_four_open_braces() {
5175        // Port of: 'should not process handlebars when invalid since triple exists'
5176        let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
5177        assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
5178    }
5179
5180    #[test]
5181    fn test_handlebars_five_open_with_valid_double() {
5182        // Port of: 'should not process handlebars when invalid but with valid triple'
5183        let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
5184        assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
5185    }
5186
5187    #[test]
5188    fn test_entities_preserved() {
5189        // Port of: 'should process entities correctly'
5190        let (fragments, _) = parse_and_get_fragments("<p>Hello&#125;World</p>");
5191        assert_fragments!(fragments, [raw("<p>Hello&#125;World</p>"),]);
5192    }
5193
5194    // ── Feature 5: DOCTYPE handling ─────────────────────────────────────
5195
5196    #[test]
5197    fn test_doctype_preserved() {
5198        // DOCTYPE should be preserved as raw content
5199        let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
5200        assert!(!fragments.is_empty());
5201        assert!(
5202            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
5203        );
5204    }
5205
5206    // ── Feature 6: Component attribute skip / multiple nested ───────────
5207
5208    #[test]
5209    fn test_component_attr_skip() {
5210        // Port of: 'should set attrSkip for skipped component attributes'
5211        let mut parser = HtmlParser::new();
5212        parser
5213            .component_registry
5214            .register_component(ComponentRegistration::new(
5215                "custom-element",
5216                "<slot></slot>",
5217                None,
5218                true,
5219            ))
5220            .expect("register");
5221        let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
5222        let result = parser.parse("index.html", html);
5223        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5224        let records = parser.into_fragment_records();
5225
5226        // <custom-element, :config(attrStart), class(attrSkip), style(attrSkip),
5227        // role(attrSkip), data-test(attrSkip), aria-test(attrSkip), >, component, </custom-element>
5228        assert_fragments!(
5229            records["index.html"].fragments,
5230            [
5231                raw("<custom-element"),
5232                // :config with attrStart
5233                attr_complex_start(":config", "config"),
5234                // Skipped attrs
5235                attr_skip("class", "value0"),
5236                attr_skip("style", "value1"),
5237                attr_skip("role", "value2"),
5238                attr_skip("data-test", "value3"),
5239                attr_skip("aria-test", "value4"),
5240                structural_matcher("streaming_root:custom-element"),
5241                raw(">"),
5242                component("custom-element"),
5243                raw("</custom-element>"),
5244            ]
5245        );
5246    }
5247
5248    #[test]
5249    fn test_component_attr_skip_static_and_embedded() {
5250        // Skipped attrs with static values and embedded bindings should
5251        // emit fragments (not be silently dropped).
5252        let mut parser = HtmlParser::new();
5253        parser
5254            .component_registry
5255            .register_component(ComponentRegistration::new(
5256                "item-group",
5257                "<slot></slot>",
5258                None,
5259                true,
5260            ))
5261            .expect("register");
5262
5263        let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
5264        let result = parser.parse("index.html", html);
5265        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5266        let records = parser.into_fragment_records();
5267
5268        assert_fragments!(
5269            records["index.html"].fragments,
5270            [
5271                raw("<item-group"),
5272                attr_skip_raw("role", "list"),
5273                attr_skip_template("aria-labelledby", "attr-1"),
5274                attr_skip_template("data-testid", "attr-2"),
5275                attr_skip_raw("class", "fixed-class"),
5276                structural_matcher("streaming_root:item-group"),
5277                raw(">"),
5278                component("item-group"),
5279                raw("</item-group>"),
5280            ]
5281        );
5282
5283        // Verify the embedded-binding template sub-streams exist and
5284        // contain the expected static + signal fragments.
5285        assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
5286        assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
5287    }
5288
5289    #[test]
5290    fn test_component_multiple_nested() {
5291        // Port of: 'handle multiple nested web components'
5292        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5293        parser
5294            .component_registry
5295            .register_component(ComponentRegistration::new(
5296                "custom-element",
5297                "<custom-child></custom-child><slot></slot>",
5298                None,
5299                true,
5300            ))
5301            .expect("register");
5302        parser
5303            .component_registry
5304            .register_component(ComponentRegistration::new(
5305                "custom-button",
5306                "<slot></slot>",
5307                None,
5308                true,
5309            ))
5310            .expect("register");
5311        parser
5312            .component_registry
5313            .register_component(ComponentRegistration::new(
5314                "custom-child",
5315                "<h1>Hello World!</h1>",
5316                None,
5317                true,
5318            ))
5319            .expect("register");
5320
5321        let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
5322        let result = parser.parse("index.html", html);
5323        assert!(result.is_ok(), "Parse error: {:?}", result.err());
5324        let records = parser.into_fragment_records();
5325
5326        // Entry stream
5327        assert_fragments!(
5328            records["index.html"].fragments,
5329            [for_loop("item", "items", "for-1"),]
5330        );
5331
5332        // For stream
5333        assert_stream!(
5334            records,
5335            "for-1",
5336            [
5337                raw("<custom-element"),
5338                structural_matcher("streaming_root:custom-element"),
5339                raw(">"),
5340                component("custom-element"),
5341                raw("<custom-button"),
5342                structural_matcher("streaming_root:custom-button"),
5343                raw(">"),
5344                component("custom-button"),
5345                raw("Ok</custom-button></custom-element>"),
5346            ]
5347        );
5348
5349        // Component streams — custom-element has contains() checks, keep manual
5350        let ce = &records["custom-element"].fragments;
5351        assert_eq!(ce.len(), 5);
5352        assert!(
5353            matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
5354        );
5355        assert!(
5356            matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
5357        );
5358        assert!(
5359            matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><slot></slot>"))
5360        );
5361
5362        assert_stream!(records, "custom-button", [raw("<slot></slot>"),]);
5363
5364        assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
5365    }
5366
5367    // ── Error handling tests ──────────────────────────────────────────
5368
5369    #[test]
5370    fn test_invalid_markup_returns_error() {
5371        // Port of: 'should fail with invalid markup'
5372        let mut parser = HtmlParser::new();
5373        let result = parser.parse("index.html", "<div><span>Unclosed div");
5374        assert!(matches!(result, Err(ParserError::Template(diag)) if
5375            diag.to_string().contains("unclosed <div> tag")
5376        ));
5377    }
5378
5379    #[test]
5380    fn test_unterminated_opening_tag_returns_error() {
5381        let mut parser = HtmlParser::new();
5382        let result = parser.parse("index.html", "before <div");
5383        assert!(matches!(result, Err(ParserError::Template(diag)) if
5384            diag.to_string().contains("malformed HTML tag")
5385        ));
5386    }
5387
5388    #[test]
5389    fn test_unterminated_html_comment_returns_error() {
5390        let mut parser = HtmlParser::new();
5391        let result = parser.parse("index.html", "<div><!-- missing close</div>");
5392        assert!(matches!(result, Err(ParserError::Template(diag)) if
5393            diag.to_string().contains("unclosed <div> tag")
5394                || diag.to_string().contains("unterminated HTML comment")
5395        ));
5396    }
5397
5398    #[test]
5399    fn test_unexpected_closing_tag_returns_error() {
5400        let mut parser = HtmlParser::new();
5401        let result = parser.parse("index.html", "</span>");
5402        assert!(matches!(result, Err(ParserError::Template(diag)) if
5403            diag.to_string().contains("unexpected closing tag </span>")
5404        ));
5405    }
5406
5407    #[test]
5408    fn test_rejects_templates_over_size_limit() {
5409        let mut parser = HtmlParser::new();
5410        let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
5411        let result = parser.parse("index.html", &html);
5412
5413        assert!(matches!(result, Err(ParserError::Html(message)) if
5414            message.contains("exceeds") && message.contains("parser limit")
5415        ));
5416    }
5417
5418    #[test]
5419    fn test_rejects_templates_over_depth_limit() {
5420        let mut parser = HtmlParser::new();
5421        let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
5422        for _ in 0..=MAX_TEMPLATE_DEPTH {
5423            html.push_str("<div>");
5424        }
5425        for _ in 0..=MAX_TEMPLATE_DEPTH {
5426            html.push_str("</div>");
5427        }
5428
5429        let result = parser.parse("index.html", &html);
5430
5431        assert!(matches!(result, Err(ParserError::Template(diag)) if
5432            diag.to_string().contains("nesting exceeds")
5433        ));
5434    }
5435
5436    #[test]
5437    fn test_rejects_routes_over_depth_limit() {
5438        let mut parser = HtmlParser::new();
5439        parser
5440            .component_registry
5441            .register_component(ComponentRegistration::new(
5442                "route-page",
5443                "<slot></slot>",
5444                None,
5445                true,
5446            ))
5447            .expect("register");
5448
5449        let depth = MAX_TEMPLATE_DEPTH + 2;
5450        let mut html = String::with_capacity(depth * 48);
5451        for _ in 0..depth {
5452            html.push_str(r#"<route path="x" component="route-page">"#);
5453        }
5454        for _ in 0..depth {
5455            html.push_str("</route>");
5456        }
5457
5458        let result = parser.parse("index.html", &html);
5459
5460        assert!(matches!(result, Err(ParserError::Template(diag)) if
5461            diag.error_code() == Some(codes::EXCESSIVE_NESTING)
5462                && diag.to_string().contains("route nesting exceeds")
5463        ));
5464    }
5465
5466    #[test]
5467    fn test_rejects_recursive_component_templates() {
5468        let mut parser = HtmlParser::new();
5469        parser
5470            .component_registry
5471            .register_component(ComponentRegistration::new(
5472                "self-card",
5473                "<self-card></self-card>",
5474                None,
5475                true,
5476            ))
5477            .expect("register");
5478
5479        let result = parser.parse("index.html", "<self-card></self-card>");
5480
5481        assert!(matches!(result, Err(ParserError::Template(diag)) if
5482            diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
5483                && diag.to_string().contains("recursive template reference")
5484        ));
5485    }
5486
5487    // ── Integration tests ─────────────────────────────────────────────
5488
5489    #[test]
5490    fn test_complex_raw_text_full_page() {
5491        // Port of: 'should process a complex raw text'
5492        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>"#;
5493        let (fragments, _) = parse_and_get_fragments(html);
5494
5495        // DOCTYPE + <head>, head_start, head content, head_end, </head><body>,
5496        // body_start, body content, body_end, </body></html>
5497        assert!(fragments.len() >= 9);
5498        assert!(
5499            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5500                raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
5501        );
5502        assert!(
5503            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
5504                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
5505        );
5506        assert!(
5507            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5508                raw.value.contains("<title>Test</title>"))
5509        );
5510        assert!(
5511            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
5512                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
5513        );
5514        assert!(
5515            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5516                raw.value.contains("</head>") && raw.value.ends_with("<body>"))
5517        );
5518        assert!(
5519            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
5520                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
5521        );
5522        assert!(
5523            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5524                raw.value.contains("<app-shell>"))
5525        );
5526        assert!(
5527            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
5528                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
5529        );
5530        assert!(
5531            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5532                raw.value.contains("</body>") && raw.value.contains("</html>"))
5533        );
5534    }
5535
5536    #[test]
5537    fn test_css_strategy_external_emits_link_tag() {
5538        let mut parser = HtmlParser::new();
5539        parser
5540            .component_registry_mut()
5541            .register_component(ComponentRegistration::new(
5542                "my-card",
5543                "<p><slot></slot></p>",
5544                Some("p { color: red; }"),
5545                true,
5546            ))
5547            .ok();
5548        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5549        let records = parser.into_fragment_records();
5550        let my_card = &records["my-card"].fragments;
5551        let raw_text: String = my_card
5552            .iter()
5553            .filter_map(|f| match &f.fragment {
5554                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5555                _ => None,
5556            })
5557            .collect();
5558        assert!(
5559            raw_text.contains(r#"<link rel="stylesheet" href="my-card.css">"#),
5560            "Expected external <link> tag in: {}",
5561            raw_text
5562        );
5563    }
5564
5565    #[test]
5566    fn test_uppercase_style_element_is_processed_as_css() {
5567        // HTML tag names are ASCII case-insensitive: an uppercase `<STYLE>`
5568        // block must be CSS-processed exactly like `<style>`, so a CSS signal
5569        // comment becomes a Signal fragment (not inert raw text) and the
5570        // reconstructed closing tag mirrors the source case.
5571        let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
5572        assert!(
5573            fragments.iter().any(|f| matches!(
5574                f.fragment.as_ref(),
5575                Some(Fragment::Signal(s)) if s.value == "tokens" && !s.raw
5576            )),
5577            "uppercase <STYLE> should be CSS-processed, got: {:?}",
5578            fragments
5579        );
5580        let raw: String = fragments
5581            .iter()
5582            .filter_map(|f| match f.fragment.as_ref() {
5583                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5584                _ => None,
5585            })
5586            .collect();
5587        assert!(
5588            raw.contains("</STYLE>"),
5589            "closing tag should preserve source case, got: {raw}"
5590        );
5591    }
5592
5593    #[test]
5594    fn test_css_strategy_inline_emits_style_tag() {
5595        let mut parser = HtmlParser::with_options(CssStrategy::Style);
5596        parser
5597            .component_registry_mut()
5598            .register_component(ComponentRegistration::new(
5599                "my-card",
5600                "<p><slot></slot></p>",
5601                Some("p { color: red; }"),
5602                true,
5603            ))
5604            .ok();
5605        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5606        let records = parser.into_fragment_records();
5607        let my_card = &records["my-card"].fragments;
5608        let raw_text: String = my_card
5609            .iter()
5610            .filter_map(|f| match &f.fragment {
5611                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5612                _ => None,
5613            })
5614            .collect();
5615        assert!(
5616            raw_text.contains("<style>p { color: red; }</style>"),
5617            "Expected inline <style> tag in: {}",
5618            raw_text
5619        );
5620        assert!(
5621            !raw_text.contains("<link"),
5622            "Should not have <link> tag in inline mode: {}",
5623            raw_text
5624        );
5625    }
5626
5627    #[test]
5628    fn test_css_strategy_module_emits_adopted_stylesheets() {
5629        let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
5630        parser
5631            .component_registry_mut()
5632            .register_component(ComponentRegistration::new(
5633                "my-card",
5634                "<p><slot></slot></p>",
5635                Some("p { color: red; }"),
5636                true,
5637            ))
5638            .ok();
5639        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5640        let records = parser.into_fragment_records();
5641
5642        // Component template should have shadowrootadoptedstylesheets, no CSS
5643        // module in raw fragments (CSS lives on the component fragment's css field)
5644        let my_card = &records["my-card"].fragments;
5645        let template_text: String = my_card
5646            .iter()
5647            .filter_map(|f| match &f.fragment {
5648                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5649                _ => None,
5650            })
5651            .collect();
5652        assert!(
5653            !template_text.contains("shadowrootadoptedstylesheets"),
5654            "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
5655        );
5656        assert!(
5657            !template_text.contains("<link"),
5658            "Should not have <link> in module mode: {template_text}"
5659        );
5660        // No CSS module baked into raw fragments — CSS is stored in
5661        // protocol.components, populated by the build system, and emitted
5662        // by the handler (SSR inline + SPA partials) as importmap scripts.
5663        assert!(
5664            !template_text.contains(r#"<style type="module""#),
5665            "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
5666        );
5667        assert!(
5668            !template_text.contains(r#"<script type="importmap""#),
5669            "CSS module importmap should NOT be in raw fragments: {template_text}"
5670        );
5671    }
5672
5673    #[test]
5674    fn test_css_strategy_module_no_css_no_adopted_attr() {
5675        let mut parser = HtmlParser::with_options(CssStrategy::Module);
5676        parser
5677            .component_registry_mut()
5678            .register_component(ComponentRegistration::new(
5679                "my-card",
5680                "<p><slot></slot></p>",
5681                None,
5682                true,
5683            ))
5684            .ok();
5685        parser.parse("index.html", "<my-card>Hello</my-card>").ok();
5686        let records = parser.into_fragment_records();
5687        let my_card = &records["my-card"].fragments;
5688        let template_text: String = my_card
5689            .iter()
5690            .filter_map(|f| match &f.fragment {
5691                Some(Fragment::Raw(r)) => Some(r.value.as_str()),
5692                _ => None,
5693            })
5694            .collect();
5695        // No CSS → no shadowrootadoptedstylesheets attribute
5696        assert!(
5697            !template_text.contains("shadowrootadoptedstylesheets"),
5698            "Should not have adopted attr without CSS: {template_text}"
5699        );
5700    }
5701
5702    // ── Dev-authored <template> wrapper handling ────────────────────
5703    //
5704    // These tests verify that when a developer includes a `<template>`
5705    // wrapper in their component HTML, the framework respects it instead
5706    // of stripping/normalizing it:
5707    //
5708    //   --dom=light  : dev `<template ...>` preserved verbatim (including
5709    //                  signal-fragment attrs like `foo="{{foo}}"`); no
5710    //                  wrapper added when dev omits one.
5711    //   --dom=shadow : dev `<template ...>` preserved verbatim (framework
5712    //                  does NOT inject `shadowrootmode="open"` or overwrite
5713    //                  a dev-supplied `shadowrootmode="closed"`); wrapper
5714    //                  added only when dev omits one.
5715    //
5716    // Calls `process_component_template` directly so the assertions observe
5717    // the exact HTML string the framework emits for the component template
5718    // (this is what gets handed to the inner parse + plugin hooks).
5719
5720    #[test]
5721    fn light_preserves_dev_template_with_static_attrs() {
5722        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5723        let processed = parser
5724            .process_component_template(
5725                r#"<template foo="bar"><div>hi</div></template>"#,
5726                None,
5727                None,
5728            )
5729            .expect("process failed");
5730        assert!(
5731            processed.contains(r#"<template foo="bar">"#),
5732            "[--dom=light] expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
5733        );
5734        assert!(
5735            processed.contains("</template>"),
5736            "[--dom=light] expected closing </template> preserved, got: {processed}"
5737        );
5738    }
5739
5740    #[test]
5741    fn light_preserves_dev_template_with_signal_fragment_attrs() {
5742        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5743        let processed = parser
5744            .process_component_template(
5745                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
5746                None,
5747                None,
5748            )
5749            .expect("process failed");
5750        assert!(
5751            processed.contains(r#"<template foo="{{foo}}">"#),
5752            "[--dom=light] expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
5753        );
5754    }
5755
5756    #[test]
5757    fn light_preserves_dev_template_with_multiple_attrs() {
5758        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5759        let processed = parser.process_component_template(
5760            r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
5761            None,
5762            None,
5763        )
5764        .expect("process failed");
5765        assert!(
5766            processed.contains("autofocus")
5767                && processed.contains(r#"tabindex="0""#)
5768                && processed.contains(r#"role="region""#)
5769                && processed.contains(r#"data-x="y""#),
5770            "[--dom=light] expected ALL dev template attrs preserved, got: {processed}"
5771        );
5772    }
5773
5774    #[test]
5775    fn light_no_template_no_wrapper_added() {
5776        let mut parser = HtmlParser::with_options(DomStrategy::Light);
5777        let processed = parser
5778            .process_component_template("<div>hi</div>", None, None)
5779            .expect("process failed");
5780        assert!(
5781            !processed.contains("<template"),
5782            "[--dom=light] framework must NOT add <template> wrapper when dev omits one, got: {processed}"
5783        );
5784        assert!(
5785            processed.contains("<div>hi</div>"),
5786            "[--dom=light] expected inner content emitted as-is, got: {processed}"
5787        );
5788    }
5789
5790    #[test]
5791    fn shadow_preserves_dev_template_with_static_attrs() {
5792        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5793        let processed = parser
5794            .process_component_template(
5795                r#"<template foo="bar"><div>hi</div></template>"#,
5796                None,
5797                None,
5798            )
5799            .expect("process failed");
5800        assert!(
5801            processed.contains(r#"<template foo="bar">"#),
5802            "[--dom=shadow] dev <template foo=\"bar\"> must be preserved verbatim, got: {processed}"
5803        );
5804        assert!(
5805            !processed.contains(r#"shadowrootmode="open""#),
5806            "[--dom=shadow] framework must NOT inject shadowrootmode when dev already supplied a <template>, got: {processed}"
5807        );
5808    }
5809
5810    #[test]
5811    fn shadow_preserves_dev_template_with_shadowrootmode_closed() {
5812        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5813        let processed = parser
5814            .process_component_template(
5815                r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
5816                None,
5817                None,
5818            )
5819            .expect("process failed");
5820        assert!(
5821            processed.contains(r#"shadowrootmode="closed""#),
5822            "[--dom=shadow] framework must respect dev's shadowrootmode=\"closed\" (developer is managing), got: {processed}"
5823        );
5824        assert!(
5825            !processed.contains(r#"shadowrootmode="open""#),
5826            "[--dom=shadow] framework must not overwrite dev's shadowrootmode with \"open\", got: {processed}"
5827        );
5828    }
5829
5830    #[test]
5831    fn shadow_preserves_dev_template_with_signal_fragment_attrs() {
5832        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5833        let processed = parser
5834            .process_component_template(
5835                r#"<template foo="{{foo}}"><div>hi</div></template>"#,
5836                None,
5837                None,
5838            )
5839            .expect("process failed");
5840        assert!(
5841            processed.contains(r#"<template foo="{{foo}}">"#),
5842            "[--dom=shadow] dev <template> with signal-fragment attr must be preserved, got: {processed}"
5843        );
5844    }
5845
5846    #[test]
5847    fn shadow_adds_template_wrapper_when_dev_omits_it() {
5848        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5849        let processed = parser
5850            .process_component_template("<div>hi</div>", None, None)
5851            .expect("process failed");
5852        assert!(
5853            processed.contains(r#"<template shadowrootmode="open""#),
5854            "[--dom=shadow] framework MUST add <template shadowrootmode=\"open\"> when dev omits a wrapper, got: {processed}"
5855        );
5856        assert!(
5857            processed.contains("<div>hi</div>"),
5858            "[--dom=shadow] inner content must survive wrapping, got: {processed}"
5859        );
5860        assert!(
5861            processed.contains("</template>"),
5862            "[--dom=shadow] framework-added wrapper must be closed, got: {processed}"
5863        );
5864    }
5865
5866    // ── CSS-module adoption on dev-authored <template> wrappers ────────
5867    //
5868    // Regression guard: under `CssStrategy::Module`, the
5869    // `shadowrootadoptedstylesheets` attribute is the only wire between
5870    // the framework-emitted importmap/module-script and the shadow root.
5871    // When the developer supplies their own `<template>` wrapper (e.g.
5872    // to attach `@event` handlers), the framework preserves that wrapper
5873    // and appends the CSS-module adoption attribute when it is missing.
5874
5875    #[test]
5876    fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
5877        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5878        let processed = parser
5879            .process_component_template(
5880                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5881                None,
5882                Some("my-comp"),
5883            )
5884            .expect("process failed");
5885        assert!(
5886            processed.contains(r#"shadowrootmode="open""#),
5887            "dev-authored shadowrootmode must be preserved, got: {processed}"
5888        );
5889        assert!(
5890            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5891            "module CSS should append adopted stylesheets, got: {processed}"
5892        );
5893    }
5894
5895    #[test]
5896    fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
5897        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5898        let processed = parser
5899            .process_component_template(
5900                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
5901                None,
5902                Some("my-comp"),
5903            )
5904            .unwrap();
5905        assert!(
5906            processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5907            "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
5908        );
5909        assert_eq!(
5910            processed.matches("shadowrootadoptedstylesheets").count(),
5911            1,
5912            "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
5913        );
5914    }
5915
5916    #[test]
5917    fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
5918        for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
5919            let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
5920            let built = match parser.build_component_templates(
5921                "my-comp",
5922                r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
5923                Some(":host { color: red; }"),
5924                true,
5925            ) {
5926                Ok(built) => built,
5927                Err(err) => panic!(
5928                    "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
5929                ),
5930            };
5931            let artifact = built.artifact();
5932
5933            assert!(
5934                artifact.contains(r#"shadowrootmode="open""#),
5935                "dev-authored shadowrootmode must be preserved under {dom_strategy:?}, got: {artifact}"
5936            );
5937            assert!(
5938                artifact.contains(r#"@click="{onClick()}""#),
5939                "dev-authored root event must be preserved under {dom_strategy:?}, got: {artifact}"
5940            );
5941            assert!(
5942                artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
5943                "module CSS should append adopted stylesheets under {dom_strategy:?}, got: {artifact}"
5944            );
5945            assert_eq!(
5946                artifact.matches("shadowrootadoptedstylesheets").count(),
5947                1,
5948                "module CSS should append adopted stylesheets once under {dom_strategy:?}, got: {artifact}"
5949            );
5950            assert!(
5951                !built.ssr.contains("@click"),
5952                "SSR template must still strip runtime attrs, got: {}",
5953                built.ssr
5954            );
5955        }
5956    }
5957
5958    #[test]
5959    fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
5960        // Multi-specifier scenarios: the dev wants to adopt extra sheets
5961        // alongside their own component's module. Honored verbatim — the
5962        // framework's only job is to validate that *some*
5963        // `shadowrootadoptedstylesheets` is present.
5964        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5965        let processed = parser
5966            .process_component_template(
5967                r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
5968                None,
5969                Some("my-comp"),
5970            )
5971            .unwrap();
5972        assert!(
5973            processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
5974            "dev's multi-specifier list must be preserved verbatim, got: {processed}"
5975        );
5976    }
5977
5978    #[test]
5979    fn dev_template_no_adopted_specifier_does_not_validate() {
5980        // CssStrategy::Link or CssStrategy::Style pass `adopted_specifier=None`.
5981        // Dev's <template> must be preserved verbatim and the validation
5982        // must not fire.
5983        let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
5984        let processed = parser
5985            .process_component_template(
5986                r#"<template shadowrootmode="open"><div>hi</div></template>"#,
5987                None,
5988                None,
5989            )
5990            .unwrap();
5991        assert!(
5992            !processed.contains("shadowrootadoptedstylesheets"),
5993            "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
5994        );
5995    }
5996
5997    // test_signal_with_default_value — SKIPPED
5998    // The NodeJS `<f-signal value="testSignal">Default Text</f-signal>` feature
5999    // is not supported in the Rust parser. There is no `f-signal` element
6000    // handling in HtmlParser and no corresponding fragment type in
6001    // webui_protocol.
6002
6003    // test_estimated_buffer_size — SKIPPED
6004    // The NodeJS `estimatedBufferSize` field does not exist in the Rust
6005    // WebUIFragmentRecords / WebUIProtocol types. Buffer size estimation is
6006    // not part of the Rust parser output.
6007
6008    #[test]
6009    fn test_body_start_end_injection() {
6010        // Port of: 'should inject body_start and body_end signals around body content'
6011        // Verifies body_start appears immediately after <body> and body_end
6012        // appears immediately before </body> in a full HTML page.
6013        let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
6014        let (fragments, _) = parse_and_get_fragments(html);
6015
6016        assert_fragments!(
6017            fragments,
6018            [
6019                raw("<html><head>"),
6020                structural_matcher("head_start"),
6021                raw("<title>Test</title>"),
6022                structural_matcher("head_end"),
6023                raw("</head><body>"),
6024                structural_matcher("body_start"),
6025                raw("<div>Content</div><p>More</p>"),
6026                structural_matcher("body_end"),
6027                raw("</body></html>"),
6028            ]
6029        );
6030    }
6031
6032    #[test]
6033    fn test_body_preserves_static_attributes() {
6034        // The <body> tag's attributes must be carried through to the rendered
6035        // HTML, otherwise host frames (e.g. webui-press's `<body data-layout="…">`)
6036        // lose their styling hooks. Earlier versions of `process_body_with_signals`
6037        // emitted a bare `<body>` and dropped every attribute on the element.
6038        let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
6039        let (fragments, _) = parse_and_get_fragments(html);
6040
6041        assert_fragments!(
6042            fragments,
6043            [
6044                raw("<html><head>"),
6045                structural_matcher("head_start"),
6046                raw("<title>T</title>"),
6047                structural_matcher("head_end"),
6048                raw(r#"</head><body data-layout="doc" class="page">"#),
6049                structural_matcher("body_start"),
6050                raw("<p>x</p>"),
6051                structural_matcher("body_end"),
6052                raw("</body></html>"),
6053            ]
6054        );
6055    }
6056
6057    #[test]
6058    fn test_body_preserves_dynamic_attributes() {
6059        // Dynamic attributes on <body> (e.g. `data-layout="{{layout}}"`) must
6060        // emit attribute fragments rather than being inlined as literal text,
6061        // so the handler binds them to runtime state at render time.
6062        let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
6063        let (fragments, _) = parse_and_get_fragments(html);
6064
6065        assert_fragments!(
6066            fragments,
6067            [
6068                raw("<html><head>"),
6069                structural_matcher("head_start"),
6070                raw("<title>T</title>"),
6071                structural_matcher("head_end"),
6072                raw("</head><body"),
6073                attr("data-layout", "layout"),
6074                raw(">"),
6075                structural_matcher("body_start"),
6076                raw("<p>x</p>"),
6077                structural_matcher("body_end"),
6078                raw("</body></html>"),
6079            ]
6080        );
6081    }
6082
6083    #[test]
6084    fn test_head_start_signal_ordering_with_authored_script() {
6085        // `render_streaming` preflights on `head_start` before authored async
6086        // scripts in <head>, mirroring `body_start`. `head_start` must appear
6087        // immediately after the raw `<head>` opening fragment and before any
6088        // child content, including an authored `<script>`.
6089        let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
6090        let (fragments, _) = parse_and_get_fragments(html);
6091
6092        assert_fragments!(
6093            fragments,
6094            [
6095                raw("<html><head>"),
6096                structural_matcher("head_start"),
6097                raw(r#"<script async src="./analytics.js"></script>"#),
6098                structural_matcher("head_end"),
6099                raw("</head><body>"),
6100                structural_matcher("body_start"),
6101                structural_matcher("body_end"),
6102                raw("</body></html>"),
6103            ]
6104        );
6105    }
6106
6107    #[test]
6108    fn test_head_static_attributes_preserve_legacy_opening_bytes() {
6109        // Compatibility: origin/main emitted a literal `<head>` and ignored
6110        // authored head attributes. The structural signal must not alter that
6111        // ordinary protocol/output shape.
6112        let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
6113        let (fragments, _) = parse_and_get_fragments(html);
6114
6115        assert_fragments!(
6116            fragments,
6117            [
6118                raw("<html><head>"),
6119                structural_matcher("head_start"),
6120                raw("<title>T</title>"),
6121                structural_matcher("head_end"),
6122                raw("</head><body>"),
6123                structural_matcher("body_start"),
6124                structural_matcher("body_end"),
6125                raw("</body></html>"),
6126            ]
6127        );
6128    }
6129
6130    #[test]
6131    fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
6132        // Bound head attributes were also ignored by origin/main. In
6133        // particular, do not introduce an attribute fragment before
6134        // `head_start`, because ordinary rendering must remain byte-identical.
6135        let html =
6136            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
6137        let (fragments, _) = parse_and_get_fragments(html);
6138
6139        assert_fragments!(
6140            fragments,
6141            [
6142                raw("<html><head>"),
6143                structural_matcher("head_start"),
6144                raw("<title>T</title>"),
6145                structural_matcher("head_end"),
6146                raw("</head><body>"),
6147                structural_matcher("body_start"),
6148                structural_matcher("body_end"),
6149                raw("</body></html>"),
6150            ]
6151        );
6152    }
6153
6154    #[test]
6155    fn native_head_and_body_are_ascii_case_insensitive() {
6156        let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
6157        let (fragments, _) = parse_and_get_fragments(html);
6158
6159        assert_fragments!(
6160            fragments,
6161            [
6162                raw(r#"<html><HEAD data-theme="ignored">"#),
6163                structural_matcher("head_start"),
6164                raw("<title>T</title>"),
6165                structural_matcher("head_end"),
6166                raw("</HEAD><BODY"),
6167                attr("data-layout", "layout"),
6168                raw(">"),
6169                structural_matcher("body_start"),
6170                raw("<p>x</p>"),
6171                structural_matcher("body_end"),
6172                raw("</BODY></html>"),
6173            ]
6174        );
6175    }
6176
6177    // ── `<boundary>` / `<webui-hydrate>` tests (Progressive Streaming
6178    // Hydration) ───────────────────────────────────────────────────────────
6179
6180    #[test]
6181    fn boundary_emits_no_wrapper_and_brackets_children_with_signals() {
6182        let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
6183        let (fragments, _) = parse_and_get_fragments(html);
6184
6185        // No `<boundary>`/`</boundary>` markup is emitted at
6186        // all — only the raw boundary_start/boundary_end signals bracket
6187        // the untouched children.
6188        assert_fragments!(
6189            fragments,
6190            [
6191                raw("<body>"),
6192                structural_matcher("body_start"),
6193                raw("<div>"),
6194                structural_matcher("boundary_start:0"),
6195                raw("<my-counter></my-counter>"),
6196                structural_matcher("boundary_end:0"),
6197                raw("</div>"),
6198                structural_matcher("body_end"),
6199                raw("</body>"),
6200            ]
6201        );
6202    }
6203
6204    #[test]
6205    fn boundary_sequence_increments_in_document_order_and_resets_per_entry() {
6206        let html = concat!(
6207            "<body>",
6208            r#"<boundary name="a"><p>1</p></boundary>"#,
6209            r#"<boundary name="b"><p>2</p></boundary>"#,
6210            "</body>",
6211        );
6212        let (fragments, _) = parse_and_get_fragments(html);
6213
6214        assert_fragments!(
6215            fragments,
6216            [
6217                raw("<body>"),
6218                structural_matcher("body_start"),
6219                structural_matcher("boundary_start:0"),
6220                raw("<p>1</p>"),
6221                structural_matcher("boundary_end:0"),
6222                structural_matcher("boundary_start:1"),
6223                raw("<p>2</p>"),
6224                structural_matcher("boundary_end:1"),
6225                structural_matcher("body_end"),
6226                raw("</body>"),
6227            ]
6228        );
6229
6230        // A separate top-level entry parsed on the same `HtmlParser` starts
6231        // its own boundary sequence back at 0 and does not reject "a" as a
6232        // duplicate name — sequence/name state is per top-level entry.
6233        let mut parser = HtmlParser::new();
6234        parser
6235            .parse(
6236                "first.html",
6237                r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
6238            )
6239            .expect("first entry parses");
6240        parser
6241            .parse(
6242                "second.html",
6243                r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
6244            )
6245            .expect("second entry reuses the name and sequence 0");
6246        let records = parser.into_fragment_records();
6247        assert_stream!(
6248            records,
6249            "second.html",
6250            [
6251                raw("<body>"),
6252                structural_matcher("body_start"),
6253                structural_matcher("boundary_start:0"),
6254                raw("<p>2</p>"),
6255                structural_matcher("boundary_end:0"),
6256                structural_matcher("body_end"),
6257                raw("</body>"),
6258            ]
6259        );
6260    }
6261
6262    #[test]
6263    fn boundary_missing_name_errors() {
6264        let mut parser = HtmlParser::new();
6265        let err = parser
6266            .parse("index.html", "<body><boundary><p>x</p></boundary></body>")
6267            .expect_err("a boundary without a name must error");
6268        let ParserError::Template(diag) = err else {
6269            panic!("expected ParserError::Template, got {err:?}");
6270        };
6271        assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
6272    }
6273
6274    #[test]
6275    fn boundary_dynamic_name_errors() {
6276        let mut parser = HtmlParser::new();
6277        let err = parser
6278            .parse(
6279                "index.html",
6280                r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
6281            )
6282            .expect_err("a dynamic boundary name must error");
6283        let ParserError::Template(diag) = err else {
6284            panic!("expected ParserError::Template, got {err:?}");
6285        };
6286        assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
6287    }
6288
6289    /// Boundary names stay free-form and are exposed separately for the
6290    /// protocol's cold name-to-handle table. Render fragments still carry only
6291    /// integer IDs, so response bytes never repeat author names.
6292    #[test]
6293    fn boundary_names_are_free_form_ordered_and_absent_from_fragments() {
6294        let mut parser = HtmlParser::new();
6295        let html = concat!(
6296            "<body>",
6297            r#"<boundary name="above the fold"><p>1</p></boundary>"#,
6298            r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
6299            r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
6300            "</body>",
6301        );
6302        parser
6303            .parse("index.html", html)
6304            .expect("free-form boundary names must be accepted");
6305        assert_eq!(parser.boundary_count(), 3);
6306        assert_eq!(
6307            parser.boundary_names(),
6308            ["above the fold", "feed/items #2", "ダッシュボード"]
6309        );
6310
6311        let records = parser.into_fragment_records();
6312        let fragments = &records
6313            .get("index.html")
6314            .expect("index.html fragment record")
6315            .fragments;
6316
6317        let mut starts = Vec::new();
6318        for fragment in fragments {
6319            let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() else {
6320                continue;
6321            };
6322            let value = signal.value.as_str();
6323            assert!(
6324                !value.contains("above the fold")
6325                    && !value.contains("feed/items")
6326                    && !value.contains("ダッシュボード"),
6327                "boundary names must never reach a fragment: {value}"
6328            );
6329            if let Some(sequence) = value
6330                .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
6331                .and_then(|signal| signal.strip_prefix("boundary_start:"))
6332            {
6333                starts.push(sequence.to_string());
6334            }
6335        }
6336        assert_eq!(
6337            starts,
6338            ["0", "1", "2"],
6339            "only integer sequences are emitted"
6340        );
6341    }
6342
6343    #[test]
6344    fn module_entry_srcs_records_only_preloadable_critical_entries() {
6345        let mut parser = HtmlParser::new();
6346        let html = concat!(
6347            "<head>",
6348            // Recorded: a static, module-typed, non-boundary entry.
6349            r#"<script type="module" async src="/index.js"></script>"#,
6350            // Recorded: mixed case on the tag, and a padded/mixed-case `type`
6351            // value, still identify a module. (Attribute *names* are matched
6352            // case-sensitively here, as everywhere else in this parser.)
6353            r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
6354            // Skipped: classic scripts have no ES module graph to preload.
6355            r#"<script src="/legacy.js"></script>"#,
6356            // Skipped: no `src` means nothing to resolve.
6357            r#"<script type="module">import "./x.js";</script>"#,
6358            // Skipped: a per-request binding matches no build artifact.
6359            r#"<script type="module" src="/{{bundle}}.js"></script>"#,
6360            "</head>",
6361            "<body>",
6362            // Skipped: an island loader is deferred on purpose.
6363            r#"<boundary name="weather">"#,
6364            r#"<script type="module" async src="/weather-panel.js"></script>"#,
6365            "</boundary>",
6366            // Recorded once: a bottom-of-body entry is still critical, and a
6367            // repeat of an already-seen src must not duplicate a hint.
6368            r#"<script type="module" src="/index.js"></script>"#,
6369            "</body>",
6370        );
6371        parser.parse("index.html", html).expect("parse");
6372
6373        assert_eq!(
6374            parser.module_entry_srcs(),
6375            ["/index.js", "/late.js"],
6376            "only static module entries outside boundaries are preloadable"
6377        );
6378    }
6379
6380    #[test]
6381    fn module_entry_srcs_reset_between_top_level_parses() {
6382        let mut parser = HtmlParser::new();
6383        parser
6384            .parse(
6385                "a.html",
6386                r#"<head><script type="module" src="/a.js"></script></head>"#,
6387            )
6388            .expect("parse a");
6389        assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
6390
6391        parser
6392            .parse(
6393                "b.html",
6394                r#"<head><script type="module" src="/b.js"></script></head>"#,
6395            )
6396            .expect("parse b");
6397        assert_eq!(
6398            parser.module_entry_srcs(),
6399            ["/b.js"],
6400            "each entry owns its own critical modules"
6401        );
6402    }
6403
6404    #[test]
6405    fn boundary_duplicate_name_errors() {
6406        let mut parser = HtmlParser::new();
6407        let html = concat!(
6408            "<body>",
6409            r#"<boundary name="dup"><p>1</p></boundary>"#,
6410            r#"<boundary name="dup"><p>2</p></boundary>"#,
6411            "</body>",
6412        );
6413        let err = parser
6414            .parse("index.html", html)
6415            .expect_err("a duplicate boundary name must error");
6416        let ParserError::Template(diag) = err else {
6417            panic!("expected ParserError::Template, got {err:?}");
6418        };
6419        assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
6420    }
6421
6422    #[test]
6423    fn boundary_nested_boundary_errors() {
6424        let mut parser = HtmlParser::new();
6425        let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
6426        let err = parser
6427            .parse("index.html", html)
6428            .expect_err("a nested boundary must error");
6429        let ParserError::Template(diag) = err else {
6430            panic!("expected ParserError::Template, got {err:?}");
6431        };
6432        assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
6433    }
6434
6435    #[test]
6436    fn boundary_inside_if_errors() {
6437        let mut parser = HtmlParser::new();
6438        let html =
6439            r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
6440        let err = parser
6441            .parse("index.html", html)
6442            .expect_err("a boundary inside <if> must error");
6443        let ParserError::Template(diag) = err else {
6444            panic!("expected ParserError::Template, got {err:?}");
6445        };
6446        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6447        assert!(diag.to_string().contains("<if>"), "{diag}");
6448    }
6449
6450    #[test]
6451    fn boundary_inside_for_errors() {
6452        let mut parser = HtmlParser::new();
6453        let html = r#"<body><for each="item in items"><boundary name="x"><p>x</p></boundary></for></body>"#;
6454        let err = parser
6455            .parse("index.html", html)
6456            .expect_err("a boundary inside <for> must error");
6457        let ParserError::Template(diag) = err else {
6458            panic!("expected ParserError::Template, got {err:?}");
6459        };
6460        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6461        assert!(diag.to_string().contains("<for>"), "{diag}");
6462    }
6463
6464    #[test]
6465    fn boundary_inside_reusable_component_template_errors() {
6466        // The boundary lives inside the component's own `.html` template, not
6467        // the entry template — disallowed even though the entry's usage of
6468        // `<my-widget>` looks unremarkable.
6469        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6470        parser
6471            .component_registry
6472            .register_component(ComponentRegistration::new(
6473                "my-widget",
6474                r#"<boundary name="x"><p>x</p></boundary>"#,
6475                None,
6476                true,
6477            ))
6478            .expect("register");
6479
6480        let err = parser
6481            .parse("index.html", "<body><my-widget></my-widget></body>")
6482            .expect_err("a boundary inside a component template must error");
6483        let ParserError::Template(diag) = err else {
6484            panic!("expected ParserError::Template, got {err:?}");
6485        };
6486        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6487    }
6488
6489    #[test]
6490    fn boundary_inside_component_host_content_errors() {
6491        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6492        parser
6493            .component_registry
6494            .register_component(ComponentRegistration::new(
6495                "my-widget",
6496                "<slot></slot>",
6497                None,
6498                true,
6499            ))
6500            .expect("register");
6501
6502        let err = parser
6503            .parse(
6504                "index.html",
6505                r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
6506            )
6507            .expect_err("a boundary inside component host content must error");
6508        let ParserError::Template(diag) = err else {
6509            panic!("expected ParserError::Template, got {err:?}");
6510        };
6511        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6512        assert!(
6513            diag.to_string().contains("component host content"),
6514            "{diag}"
6515        );
6516    }
6517
6518    #[test]
6519    fn boundary_inside_raw_or_inert_html_context_errors() {
6520        for (element, expected_scope) in [
6521            ("textarea", "<textarea> text content"),
6522            ("title", "<title> text content"),
6523            ("script", "<script> raw-text content"),
6524            ("xmp", "<xmp> raw-text content"),
6525            ("iframe", "<iframe> raw-text content"),
6526            ("noembed", "<noembed> raw-text content"),
6527            ("noframes", "<noframes> raw-text content"),
6528            ("noscript", "<noscript> inert content"),
6529            ("plaintext", "<plaintext> text content"),
6530            ("template", "<template> inert content"),
6531        ] {
6532            let mut parser = HtmlParser::new();
6533            let html = format!(
6534                r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
6535            );
6536            let err = parser
6537                .parse("index.html", &html)
6538                .expect_err("a boundary in raw or inert content must error");
6539            let ParserError::Template(diag) = err else {
6540                panic!("expected ParserError::Template, got {err:?}");
6541            };
6542            assert_eq!(
6543                diag.error_code(),
6544                Some(codes::BOUNDARY_CROSSES_SCOPE),
6545                "unexpected code for <{element}>"
6546            );
6547            assert!(
6548                diag.to_string().contains(expected_scope),
6549                "unexpected scope for <{element}>: {diag}"
6550            );
6551        }
6552    }
6553
6554    #[test]
6555    fn boundary_inside_route_element_errors() {
6556        let mut parser = HtmlParser::new();
6557        // Directly inside <route> children.
6558        let err = parser
6559            .parse(
6560                "index.html",
6561                r#"<body><route path="/" component="home"><boundary name="x"><p>x</p></boundary></route></body>"#,
6562            )
6563            .expect_err("a boundary directly inside <route> must error");
6564        let ParserError::Template(diag) = err else {
6565            panic!("expected ParserError::Template, got {err:?}");
6566        };
6567        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6568
6569        // Nested deeper inside <route> ignored markup.
6570        let mut parser = HtmlParser::new();
6571        let err = parser
6572            .parse(
6573                "index.html",
6574                r#"<body><route path="/" component="home"><div><boundary name="x"><p>x</p></boundary></div></route></body>"#,
6575            )
6576            .expect_err("a boundary nested inside <route> markup must error");
6577        let ParserError::Template(diag) = err else {
6578            panic!("expected ParserError::Template, got {err:?}");
6579        };
6580        assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
6581    }
6582
6583    #[test]
6584    fn boundary_must_be_inside_an_open_body() {
6585        for html in [
6586            r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
6587            r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
6588        ] {
6589            let mut parser = HtmlParser::new();
6590            let err = parser
6591                .parse("index.html", html)
6592                .expect_err("a boundary outside body must error");
6593            let ParserError::Template(diag) = err else {
6594                panic!("expected ParserError::Template, got {err:?}");
6595            };
6596            assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
6597            assert_eq!(
6598                diag.help_text(),
6599                Some("move the <boundary> between the opening <body> and its matching </body>")
6600            );
6601        }
6602
6603        let mut parser = HtmlParser::new();
6604        parser
6605            .parse(
6606                "index.html",
6607                r#"<BODY><boundary name="inside"></boundary></BODY>"#,
6608            )
6609            .expect("mixed-case native body keeps the boundary inside an open body");
6610    }
6611
6612    #[test]
6613    fn boundary_inside_foster_parenting_context_errors() {
6614        // Each of these insertion modes moves an unknown element (the
6615        // generated <webui-hydrate> sentinel) out of the subtree, which would
6616        // split it from its payload script and halt hydration at runtime.
6617        for html in [
6618            r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
6619            r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
6620            r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
6621            r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
6622            r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
6623            r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
6624            r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
6625            r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
6626            // Mixed case must be rejected identically: HTML tag names are
6627            // case-insensitive, so the browser foster-parents these too.
6628            r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
6629        ] {
6630            let mut parser = HtmlParser::new();
6631            let err = parser
6632                .parse("index.html", html)
6633                .expect_err("a boundary in a foster-parenting context must error");
6634            let ParserError::Template(diag) = err else {
6635                panic!("expected ParserError::Template, got {err:?}");
6636            };
6637            assert_eq!(
6638                diag.error_code(),
6639                Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
6640                "unexpected code for {html}"
6641            );
6642            assert!(
6643                diag.help_text()
6644                    .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
6645                "help should suggest a safe placement for {html}"
6646            );
6647        }
6648    }
6649
6650    #[test]
6651    fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
6652        // <td>/<th>/<caption> switch back to "in body" rules, so an unknown
6653        // element is *not* foster-parented and the boundary is safe even
6654        // though <table>/<tr> ancestors are hostile.
6655        for html in [
6656            r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
6657            r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
6658            r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
6659            // Wrapping the whole table is the other documented fix.
6660            r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
6661            // A <td> barrier resets the depth, an inner table raises it again,
6662            // and closing that inner table must restore the barrier's zero —
6663            // not the outer table's non-zero depth.
6664            concat!(
6665                "<body><table><tbody><tr><td>",
6666                "<table><tbody><tr><td>inner</td></tr></tbody></table>",
6667                r#"<boundary name="after-inner"><p>x</p></boundary>"#,
6668                "</td></tr></tbody></table></body>",
6669            ),
6670            // Void and self-closing elements must not leak restore ops.
6671            r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
6672        ] {
6673            let mut parser = HtmlParser::new();
6674            parser
6675                .parse("index.html", html)
6676                .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
6677        }
6678    }
6679
6680    #[test]
6681    fn foster_context_depth_is_restored_after_leaving_a_table() {
6682        // Regression guard for the save/restore op: a boundary *after* a table
6683        // must not inherit the table's hostile depth.
6684        let mut parser = HtmlParser::new();
6685        parser
6686            .parse(
6687                "index.html",
6688                concat!(
6689                    "<body>",
6690                    "<table><tbody><tr><td>x</td></tr></tbody></table>",
6691                    r#"<boundary name="after-table"><p>y</p></boundary>"#,
6692                    "</body>",
6693                ),
6694            )
6695            .expect("a boundary after a closed table must be accepted");
6696    }
6697
6698    #[test]
6699    fn authored_raw_bindings_cannot_forge_structural_signals() {
6700        let html = concat!(
6701            "<html><head></head><body>",
6702            "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
6703            "{{{boundary_start:0}}}{{{boundary_end:0}}}",
6704            "{{{streaming_root:forged}}}",
6705            "</body></html>",
6706        );
6707        let (fragments, _) = parse_and_get_fragments(html);
6708        let raw_signals: Vec<&str> = fragments
6709            .iter()
6710            .filter_map(|fragment| match fragment.fragment.as_ref() {
6711                Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
6712                _ => None,
6713            })
6714            .collect();
6715
6716        assert_eq!(
6717            raw_signals,
6718            [
6719                "}}}webui:head_start",
6720                "}}}webui:head_end",
6721                "}}}webui:body_start",
6722                "head_start",
6723                "head_end",
6724                "body_start",
6725                "body_end",
6726                "boundary_start:0",
6727                "boundary_end:0",
6728                "streaming_root:forged",
6729                "}}}webui:body_end",
6730            ]
6731        );
6732    }
6733
6734    #[test]
6735    fn authored_webui_hydrate_errors_at_top_level() {
6736        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
6737            let mut parser = HtmlParser::new();
6738            let html = format!("<div><{tag}></{tag}></div>");
6739            let err = parser
6740                .parse("index.html", &html)
6741                .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
6742            let ParserError::Template(diag) = err else {
6743                panic!("expected ParserError::Template, got {err:?}");
6744            };
6745            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
6746        }
6747    }
6748
6749    #[test]
6750    fn authored_webui_hydrate_errors_inside_route() {
6751        for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
6752            let mut parser = HtmlParser::new();
6753            let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
6754            let err = parser
6755                .parse("index.html", &html)
6756                .expect_err("an authored <webui-hydrate> inside <route> must error");
6757            let ParserError::Template(diag) = err else {
6758                panic!("expected ParserError::Template, got {err:?}");
6759            };
6760            assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
6761        }
6762    }
6763
6764    #[test]
6765    fn boundary_directive_remains_case_sensitive() {
6766        let (fragments, _) =
6767            parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
6768
6769        assert_fragments!(
6770            fragments,
6771            [raw(
6772                r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
6773            ),]
6774        );
6775    }
6776
6777    #[test]
6778    fn templates_without_webui_boundary_are_unaffected() {
6779        // Regression: ordinary templates with no `<boundary>` directive
6780        // must parse exactly as before — no boundary signals, no behavior
6781        // change for the non-streaming path.
6782        let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
6783        let (fragments, _) = parse_and_get_fragments(html);
6784
6785        assert_fragments!(
6786            fragments,
6787            [
6788                raw("<div><my-counter"),
6789                attr("count", "count"),
6790                raw("></my-counter></div>"),
6791            ]
6792        );
6793    }
6794
6795    #[test]
6796    fn test_fail_with_invalid_markup() {
6797        // Port of: 'should fail with invalid markup'
6798        let mut parser = HtmlParser::new();
6799        let result = parser.parse("index.html", "<div><span></div></span>");
6800
6801        assert!(matches!(result, Err(ParserError::Template(diag)) if
6802            diag.to_string().contains("unclosed <span> tag")
6803        ));
6804    }
6805
6806    #[test]
6807    fn test_complex_raw_text_page() {
6808        // Port of: 'should process a complex raw text page with DOCTYPE,
6809        // meta tags, styles, and scripts'
6810        let html = concat!(
6811            "<!DOCTYPE html>",
6812            "<html lang=\"en\">",
6813            "<head>",
6814            "<meta charset=\"utf-8\">",
6815            "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
6816            "<title>Complex Page</title>",
6817            "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
6818            "<link rel=\"stylesheet\" href=\"styles.css\">",
6819            "</head>",
6820            "<body>",
6821            "<h1>Hello World</h1>",
6822            "<script type=\"module\" src=\"./app.js\"></script>",
6823            "</body>",
6824            "</html>",
6825        );
6826        let (fragments, _) = parse_and_get_fragments(html);
6827
6828        // Should have: raw(DOCTYPE+<head>), head_start, raw(head content),
6829        // head_end, raw(</head><body>), body_start, raw(body content),
6830        // body_end, raw(</body></html>)
6831        assert!(
6832            fragments.len() >= 9,
6833            "Expected at least 9 fragments, got {}",
6834            fragments.len()
6835        );
6836
6837        // First fragment: DOCTYPE through the opening <head> tag
6838        assert!(
6839            matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6840                raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
6841            "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
6842            fragments[0]
6843        );
6844
6845        // head_start signal
6846        assert!(
6847            matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6848                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
6849            "Second fragment should be head_start signal"
6850        );
6851
6852        // Head content (meta, title, style, link)
6853        assert!(
6854            matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6855                raw.value.contains("<meta charset=\"utf-8\">") &&
6856                raw.value.contains("<meta name=\"viewport\"") &&
6857                raw.value.contains("<title>Complex Page</title>") &&
6858                raw.value.contains("<style>") &&
6859                raw.value.contains("body { margin: 0; padding: 0; }")),
6860            "Third fragment should contain all head content, got: {:?}",
6861            fragments[2]
6862        );
6863
6864        // head_end signal
6865        assert!(
6866            matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
6867                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
6868            "Fourth fragment should be head_end signal"
6869        );
6870
6871        // </head><body>
6872        assert!(
6873            matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6874                raw.value.contains("</head>") && raw.value.ends_with("<body>")),
6875            "Fifth fragment should contain </head><body>"
6876        );
6877
6878        // body_start signal
6879        assert!(
6880            matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6881                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
6882            "Sixth fragment should be body_start signal"
6883        );
6884
6885        // Body content (h1 and script)
6886        assert!(
6887            matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6888                raw.value.contains("<h1>Hello World</h1>") &&
6889                raw.value.contains("<script")),
6890            "Seventh fragment should contain body content"
6891        );
6892
6893        // body_end signal
6894        assert!(
6895            matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
6896                s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
6897            "Eighth fragment should be body_end signal"
6898        );
6899
6900        // Closing tags
6901        assert!(
6902            matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
6903                raw.value.contains("</body>") && raw.value.contains("</html>")),
6904            "Ninth fragment should contain closing tags"
6905        );
6906    }
6907
6908    // --- Binding count tests (with mock plugin) ---
6909
6910    /// Mock plugin that records the binding attribute count for each element.
6911    struct BindingCountPlugin {
6912        counts: Vec<u32>,
6913    }
6914
6915    impl BindingCountPlugin {
6916        fn new() -> Self {
6917            Self { counts: Vec::new() }
6918        }
6919    }
6920
6921    impl crate::plugin::ParserPlugin for BindingCountPlugin {
6922        fn register_component_template(
6923            &mut self,
6924            _tag_name: &str,
6925            _component: &Component,
6926            _processed_template: &str,
6927        ) -> Result<()> {
6928            Ok(())
6929        }
6930
6931        fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
6932            if attr_name.starts_with('@') || attr_name == "f-ref" {
6933                AttributeAction::SkipAndCountBinding
6934            } else {
6935                AttributeAction::Keep
6936            }
6937        }
6938
6939        fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
6940            self.counts.push(binding_attribute_count);
6941            if binding_attribute_count > 0 {
6942                Some(binding_attribute_count.to_le_bytes().to_vec())
6943            } else {
6944                None
6945            }
6946        }
6947    }
6948
6949    struct TemplateCapturePlugin {
6950        template: Option<String>,
6951    }
6952
6953    impl TemplateCapturePlugin {
6954        fn new() -> Self {
6955            Self { template: None }
6956        }
6957    }
6958
6959    impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
6960        fn register_component_template(
6961            &mut self,
6962            _tag_name: &str,
6963            _component: &Component,
6964            processed_template: &str,
6965        ) -> Result<()> {
6966            self.template = Some(processed_template.to_string());
6967            Ok(())
6968        }
6969
6970        fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
6971            if attr_name.starts_with('@') || attr_name == "f-ref" {
6972                AttributeAction::SkipAndCountBinding
6973            } else {
6974                AttributeAction::Keep
6975            }
6976        }
6977
6978        fn finish_element(&mut self, _binding_attribute_count: u32) -> Option<Vec<u8>> {
6979            None
6980        }
6981
6982        fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
6983            match self.template {
6984                Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
6985                    crate::plugin::ComponentTemplateArtifact::template(
6986                        "todo-app".to_string(),
6987                        template,
6988                    ),
6989                ])),
6990                None => Ok(ParserPluginArtifacts::None),
6991            }
6992        }
6993    }
6994
6995    #[test]
6996    fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
6997        let mut parser = HtmlParser::with_plugin_options(
6998            Box::new(TemplateCapturePlugin::new()),
6999            CssStrategy::Link,
7000        );
7001        parser
7002            .component_registry_mut()
7003            .register_component(ComponentRegistration::new(
7004                "todo-app",
7005                r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
7006                Some(":host { display: block; }"),
7007             true,))
7008            .expect("register todo-app");
7009
7010        parser
7011            .parse("index.html", "<todo-app></todo-app>")
7012            .expect("parse failed");
7013
7014        let artifacts = parser.take_plugin_artifacts().expect("artifacts");
7015        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
7016            panic!("expected captured component template");
7017        };
7018        let template = &templates[0].template;
7019
7020        assert!(
7021            template.contains(r#"shadowrootmode="open""#),
7022            "parser must pass dev-authored root template attrs to plugins, got: {template}"
7023        );
7024        assert!(
7025            template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
7026            "parser must pass dev-authored root event attrs to plugins, got: {template}"
7027        );
7028        assert!(
7029            template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
7030            "parser must pass dev-authored root event attrs to plugins, got: {template}"
7031        );
7032        assert!(
7033            template.contains(r#"f-ref="{root}""#),
7034            "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
7035        );
7036        assert!(
7037            !template.contains("shadowrootadoptedstylesheets"),
7038            "link CSS must not add CSS module adoption attrs, got: {template}"
7039        );
7040    }
7041
7042    #[test]
7043    fn test_component_static_attrs_not_counted_as_bindings() {
7044        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7045        parser
7046            .component_registry
7047            .register_component(ComponentRegistration::new(
7048                "my-btn",
7049                "<button><slot></slot></button>",
7050                None,
7051                true,
7052            ))
7053            .expect("register");
7054
7055        // All attributes are static — binding count should be 0
7056        let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
7057        let result = parser.parse("index.html", html);
7058        assert!(result.is_ok());
7059
7060        let records = parser.into_fragment_records();
7061
7062        // No Plugin fragment should appear (binding count = 0)
7063        let frags = &records["index.html"].fragments;
7064        let plugin_count = frags
7065            .iter()
7066            .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
7067            .count();
7068        assert_eq!(
7069            plugin_count, 0,
7070            "Static-only component attributes should not emit a Plugin fragment"
7071        );
7072    }
7073
7074    #[test]
7075    fn test_component_dynamic_attr_counted_as_binding() {
7076        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7077        parser
7078            .component_registry
7079            .register_component(ComponentRegistration::new(
7080                "my-btn",
7081                "<button><slot></slot></button>",
7082                None,
7083                true,
7084            ))
7085            .expect("register");
7086
7087        // One dynamic attribute ({{...}}) — binding count should be 1
7088        let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
7089        let result = parser.parse("index.html", html);
7090        assert!(result.is_ok());
7091
7092        let records = parser.into_fragment_records();
7093        let frags = &records["index.html"].fragments;
7094
7095        // Exactly one Plugin fragment with count = 1
7096        let plugin_frags: Vec<_> = frags
7097            .iter()
7098            .filter_map(|f| match f.fragment.as_ref() {
7099                Some(Fragment::Plugin(p)) => Some(&p.data),
7100                _ => None,
7101            })
7102            .collect();
7103        assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
7104        let count = u32::from_le_bytes([
7105            plugin_frags[0][0],
7106            plugin_frags[0][1],
7107            plugin_frags[0][2],
7108            plugin_frags[0][3],
7109        ]);
7110        assert_eq!(count, 1, "Binding attribute count should be 1");
7111    }
7112
7113    #[test]
7114    fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
7115        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7116        parser
7117            .component_registry
7118            .register_component(ComponentRegistration::new(
7119                "my-btn",
7120                "<button><slot></slot></button>",
7121                None,
7122                true,
7123            ))
7124            .expect("register");
7125
7126        // 2 static, 1 dynamic, 1 skipped-with-plugin (@click) — only dynamic + skipped counted
7127        let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
7128        let result = parser.parse("index.html", html);
7129        assert!(result.is_ok());
7130
7131        let records = parser.into_fragment_records();
7132        let frags = &records["index.html"].fragments;
7133
7134        let plugin_frags: Vec<_> = frags
7135            .iter()
7136            .filter_map(|f| match f.fragment.as_ref() {
7137                Some(Fragment::Plugin(p)) => Some(&p.data),
7138                _ => None,
7139            })
7140            .collect();
7141        assert_eq!(plugin_frags.len(), 1);
7142        let count = u32::from_le_bytes([
7143            plugin_frags[0][0],
7144            plugin_frags[0][1],
7145            plugin_frags[0][2],
7146            plugin_frags[0][3],
7147        ]);
7148        // 1 dynamic (appearance) + 1 skipped-but-counted (@click) = 2
7149        assert_eq!(
7150            count, 2,
7151            "Binding count should include dynamic + plugin-skipped attrs, not static"
7152        );
7153    }
7154
7155    #[test]
7156    fn test_component_only_skipped_plugin_attrs_counted() {
7157        let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
7158        parser
7159            .component_registry
7160            .register_component(ComponentRegistration::new(
7161                "my-btn",
7162                "<button><slot></slot></button>",
7163                None,
7164                true,
7165            ))
7166            .expect("register");
7167
7168        // Only plugin-skipped attrs (@click, f-ref) plus static — only skipped counted
7169        let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
7170        let result = parser.parse("index.html", html);
7171        assert!(result.is_ok());
7172
7173        let records = parser.into_fragment_records();
7174        let frags = &records["index.html"].fragments;
7175
7176        let plugin_frags: Vec<_> = frags
7177            .iter()
7178            .filter_map(|f| match f.fragment.as_ref() {
7179                Some(Fragment::Plugin(p)) => Some(&p.data),
7180                _ => None,
7181            })
7182            .collect();
7183        assert_eq!(plugin_frags.len(), 1);
7184        let count = u32::from_le_bytes([
7185            plugin_frags[0][0],
7186            plugin_frags[0][1],
7187            plugin_frags[0][2],
7188            plugin_frags[0][3],
7189        ]);
7190        // @click + f-ref = 2, title is static = not counted
7191        assert_eq!(
7192            count, 2,
7193            "Only plugin-skipped attrs should be counted, not static title"
7194        );
7195    }
7196
7197    // ── Comment stripping tests ──────────────────────────────────────
7198
7199    #[test]
7200    fn test_comment_handlebars_signal_is_stripped() {
7201        let mut parser = HtmlParser::new();
7202        let html = "<!--{{tokens}}-->";
7203        parser.parse("test.html", html).expect("parse failed");
7204        let records = parser.into_fragment_records();
7205
7206        assert_stream!(records, "test.html", []);
7207    }
7208
7209    #[test]
7210    fn test_comment_triple_brace_raw_signal_is_stripped() {
7211        let mut parser = HtmlParser::new();
7212        let html = "<!--{{{tokens}}}-->";
7213        parser.parse("test.html", html).expect("parse failed");
7214        let records = parser.into_fragment_records();
7215
7216        assert_stream!(records, "test.html", []);
7217    }
7218
7219    #[test]
7220    fn test_comment_regular_stripped() {
7221        let mut parser = HtmlParser::new();
7222        let html = "<!-- regular comment -->";
7223        parser.parse("test.html", html).expect("parse failed");
7224        let records = parser.into_fragment_records();
7225
7226        assert_stream!(records, "test.html", []);
7227    }
7228
7229    #[test]
7230    fn test_comment_dotted_signal_is_stripped() {
7231        let mut parser = HtmlParser::new();
7232        let html = "<!--{{tokens.light}}-->";
7233        parser.parse("test.html", html).expect("parse failed");
7234        let records = parser.into_fragment_records();
7235
7236        assert_stream!(records, "test.html", []);
7237    }
7238
7239    #[test]
7240    fn test_comment_arbitrary_identifier_is_stripped() {
7241        let mut parser = HtmlParser::new();
7242        let html = "<!--{{someOtherBinding}}-->";
7243        parser.parse("test.html", html).expect("parse failed");
7244        let records = parser.into_fragment_records();
7245
7246        assert_stream!(records, "test.html", []);
7247    }
7248
7249    #[test]
7250    fn test_comment_with_surrounding_content_is_stripped() {
7251        let mut parser = HtmlParser::new();
7252        let html = "<div><!--{{tokens}}--></div>";
7253        parser.parse("test.html", html).expect("parse failed");
7254        let records = parser.into_fragment_records();
7255
7256        assert_stream!(records, "test.html", [raw("<div></div>")]);
7257    }
7258
7259    #[test]
7260    fn test_component_template_unterminated_comment_returns_error() {
7261        let mut parser = HtmlParser::new();
7262        parser
7263            .component_registry_mut()
7264            .register_component(ComponentRegistration::new(
7265                "x-bad",
7266                "<span>ok</span><!-- missing close",
7267                None,
7268                true,
7269            ))
7270            .expect("register failed");
7271
7272        let result = parser.parse("index.html", "<x-bad></x-bad>");
7273
7274        assert!(matches!(result, Err(ParserError::Template(diag)) if
7275            diag.to_string().contains("unterminated HTML comment")
7276        ));
7277    }
7278
7279    #[test]
7280    fn test_webui_plugin_strips_component_comments_before_metadata() {
7281        let mut parser =
7282            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
7283        parser
7284            .component_registry_mut()
7285            .register_component(ComponentRegistration::new(
7286                "x-bleed",
7287                r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
7288                None,
7289                true,
7290            ))
7291            .expect("register failed");
7292
7293        parser
7294            .parse("index.html", "<x-bleed></x-bleed>")
7295            .expect("parse failed");
7296
7297        let artifacts = parser.take_plugin_artifacts().unwrap();
7298        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
7299            panic!("expected component templates");
7300        };
7301        let template = &templates[0].template_json;
7302        assert!(template.contains("<div>hello</div>"));
7303        assert!(!template.contains("{{path}}"));
7304        assert!(!template.contains("@click"));
7305        assert!(!template.contains("?hidden"));
7306        assert!(!template.contains("-->"));
7307    }
7308
7309    #[test]
7310    fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
7311        let mut parser =
7312            HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
7313        parser
7314            .component_registry_mut()
7315            .register_component(ComponentRegistration::new(
7316                "x-style",
7317                "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
7318                None,
7319                true,
7320            ))
7321            .expect("register failed");
7322
7323        parser
7324            .parse("index.html", "<x-style></x-style>")
7325            .expect("parse failed");
7326
7327        let artifacts = parser.take_plugin_artifacts().unwrap();
7328        let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
7329            panic!("expected component templates");
7330        };
7331        let template = &templates[0].template_json;
7332        assert!(template.contains(".x { color: red; }"));
7333        assert!(!template.contains("ignored"));
7334        assert!(!template.contains("//"));
7335    }
7336
7337    // ── Token collection tests ───────────────────────────────────────
7338
7339    #[test]
7340    fn test_tokens_from_style_tag() {
7341        let mut parser = HtmlParser::new();
7342        let html = r#"<style>
7343            .btn { color: var(--colorPrimary); background: var(--bgColor); }
7344        </style>"#;
7345        parser.parse("test.html", html).expect("parse failed");
7346        let tokens = parser.take_tokens();
7347
7348        assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
7349    }
7350
7351    #[test]
7352    fn test_tokens_from_malformed_style_error_on_unclosed_var() {
7353        let mut parser = HtmlParser::new();
7354        let html = r#"<style>
7355            .bad { color: var(--dangling; }
7356            .ok { color: var(--valid); }
7357        </style>"#;
7358        let result = parser.parse("test.html", html);
7359
7360        assert!(matches!(result, Err(ParserError::Template(diag)) if
7361            diag.error_code() == Some(codes::INVALID_CSS)
7362                && diag.to_string().contains("Unterminated CSS var() call")
7363        ));
7364    }
7365
7366    #[test]
7367    fn test_tokens_from_component_css() {
7368        let mut parser = HtmlParser::new();
7369        parser
7370            .component_registry_mut()
7371            .register_component(ComponentRegistration::new(
7372                "my-button",
7373                "<button>Click</button>",
7374                Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
7375                true,
7376            ))
7377            .expect("register failed");
7378
7379        let html = "<my-button></my-button>";
7380        parser.parse("test.html", html).expect("parse failed");
7381        let tokens = parser.take_tokens();
7382
7383        assert_eq!(tokens, vec!["borderWidth", "textColor"]);
7384    }
7385
7386    #[test]
7387    fn test_token_requirements_preserve_fallback_chains() {
7388        let mut parser = HtmlParser::new();
7389        parser
7390            .component_registry_mut()
7391            .register_component(ComponentRegistration::new(
7392                "my-card",
7393                "<div>Card</div>",
7394                Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
7395                true,
7396            ))
7397            .expect("register failed");
7398
7399        parser
7400            .parse("test.html", "<my-card></my-card>")
7401            .expect("parse failed");
7402        let analysis = parser.token_analysis();
7403
7404        assert_eq!(analysis.fallback_chains.len(), 1);
7405        assert_eq!(
7406            analysis.fallback_chains[0].tokens,
7407            vec!["token-a", "token-b", "token-c"]
7408        );
7409        assert!(!analysis.fallback_chains[0].has_literal_fallback);
7410    }
7411
7412    #[test]
7413    fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
7414        let mut parser = HtmlParser::new();
7415        parser
7416            .component_registry_mut()
7417            .register_component(ComponentRegistration::new(
7418                "my-card",
7419                "<div>Card</div>",
7420                Some(
7421                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
7422                ),
7423             true,))
7424            .expect("register failed");
7425
7426        parser
7427            .parse("test.html", "<my-card></my-card>")
7428            .expect("parse failed");
7429        let analysis = parser.token_analysis();
7430
7431        assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
7432        assert_eq!(analysis.fallback_chains.len(), 1);
7433        assert_eq!(
7434            analysis.fallback_chains[0].tokens,
7435            vec!["token-b", "token-c"]
7436        );
7437    }
7438
7439    #[test]
7440    fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
7441        let mut parser = HtmlParser::new();
7442        parser
7443            .component_registry_mut()
7444            .register_component(ComponentRegistration::new(
7445                "my-card",
7446                "<div>Card</div>",
7447                Some(
7448                    ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
7449                ),
7450             true,))
7451            .expect("register failed");
7452        parser
7453            .parse("test.html", "<my-card></my-card>")
7454            .expect("parse failed");
7455
7456        let analysis = parser.token_analysis();
7457        let theme = webui_tokens::TokenFile {
7458            themes: HashMap::from([(
7459                "light".to_string(),
7460                HashMap::from([("token-b".to_string(), "green".to_string())]),
7461            )]),
7462        };
7463
7464        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
7465            panic!("missing --token-c in the theme must fail parser validation");
7466        };
7467        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
7468        // The help is concise: a typo suggestion plus the local-definition
7469        // escape hatch — it does not restate "add --token-c to theme".
7470        let help = diagnostic.help_text().expect("help text");
7471        assert!(help.contains("did you mean --token-b?"), "help: {help}");
7472        assert!(help.contains("define it locally"), "help: {help}");
7473        assert!(!help.contains("add --token"), "help: {help}");
7474    }
7475
7476    #[test]
7477    fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
7478        let mut parser = HtmlParser::new();
7479        parser
7480            .component_registry_mut()
7481            .register_component(ComponentRegistration::new(
7482                "my-card",
7483                "<div>Card</div>",
7484                Some(":host { color: var(--brand, #000); }"),
7485                true,
7486            ))
7487            .expect("register failed");
7488        parser
7489            .parse("test.html", "<my-card></my-card>")
7490            .expect("parse failed");
7491
7492        let analysis = parser.token_analysis();
7493        // The token is still hoisted so the runtime resolves it when a theme
7494        // does provide it.
7495        assert_eq!(analysis.protocol_tokens, vec!["brand"]);
7496
7497        // A theme without `--brand` must NOT fail the build: the CSS literal
7498        // fallback (`#000`) already provides a value.
7499        let theme = webui_tokens::TokenFile {
7500            themes: HashMap::from([("light".to_string(), HashMap::new())]),
7501        };
7502        analysis
7503            .validate_theme_tokens(&theme)
7504            .expect("literal fallback must exempt --brand from theme validation");
7505    }
7506
7507    #[test]
7508    fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
7509        let mut parser = HtmlParser::new();
7510        parser
7511            .component_registry_mut()
7512            .register_component(ComponentRegistration::new(
7513                "my-card",
7514                "<div>Card</div>",
7515                // One usage has a literal fallback, the other does not. The
7516                // bare `var(--brand)` makes the token genuinely required.
7517                Some(":host { color: var(--brand, #000); background: var(--brand); }"),
7518                true,
7519            ))
7520            .expect("register failed");
7521        parser
7522            .parse("test.html", "<my-card></my-card>")
7523            .expect("parse failed");
7524
7525        let analysis = parser.token_analysis();
7526        let theme = webui_tokens::TokenFile {
7527            themes: HashMap::from([("light".to_string(), HashMap::new())]),
7528        };
7529
7530        let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
7531            panic!("a bare var(--brand) usage must require --brand in the theme");
7532        };
7533        assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
7534        assert!(diagnostic.to_string().contains("--brand"));
7535    }
7536
7537    #[test]
7538    fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
7539        let mut parser = HtmlParser::new();
7540        parser
7541            .component_registry_mut()
7542            .register_component(ComponentRegistration::new(
7543                "my-card",
7544                "<div>Card</div>",
7545                Some(
7546                    ":host { \
7547                       color: var(--colr-brand, #000); \
7548                       border: var(--present, 1px); \
7549                       margin: var(--required); \
7550                     }",
7551                ),
7552                true,
7553            ))
7554            .expect("register failed");
7555        parser
7556            .parse("test.html", "<my-card></my-card>")
7557            .expect("parse failed");
7558
7559        let analysis = parser.token_analysis();
7560        let theme = webui_tokens::TokenFile {
7561            themes: HashMap::from([(
7562                "light".to_string(),
7563                HashMap::from([
7564                    ("present".to_string(), "2px".to_string()),
7565                    ("required".to_string(), "8px".to_string()),
7566                ]),
7567            )]),
7568        };
7569
7570        // `colr-brand`: literal-only and absent from every theme → warned.
7571        // `present`: literal-only but defined in the theme → not warned.
7572        // `required`: has no literal fallback → a validation concern, not a warning.
7573        assert_eq!(
7574            analysis.unthemed_literal_fallback_tokens(&theme),
7575            vec!["colr-brand".to_string()]
7576        );
7577    }
7578
7579    #[test]
7580    fn test_theme_token_error_reports_location_and_suggestion() {
7581        let mut parser = HtmlParser::new();
7582        parser
7583            .component_registry_mut()
7584            .register_component(ComponentRegistration::new(
7585                "my-card",
7586                "<div>Card</div>",
7587                ":host {\n  color: var(--color-neutral-2000);\n}".into(),
7588                true,
7589            ))
7590            .expect("register failed");
7591        parser
7592            .parse("test.html", "<my-card></my-card>")
7593            .expect("parse failed");
7594
7595        let analysis = parser.token_analysis();
7596        let theme = webui_tokens::TokenFile {
7597            themes: HashMap::from([(
7598                "dark".to_string(),
7599                HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
7600            )]),
7601        };
7602
7603        let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
7604            panic!("missing --color-neutral-2000 must fail validation");
7605        };
7606        assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
7607        // File + line:column, like other authoring diagnostics.
7608        let location = diag.location().expect("a source location");
7609        assert!(location.contains("my-card.css:2:"), "location: {location}");
7610        // Snippet shows the offending CSS line.
7611        assert!(
7612            diag.snippet_text()
7613                .is_some_and(|s| s.contains("--color-neutral-2000")),
7614            "snippet: {:?}",
7615            diag.snippet_text()
7616        );
7617        // Did-you-mean from the theme's own tokens.
7618        assert!(
7619            diag.help_text()
7620                .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
7621            "help: {:?}",
7622            diag.help_text()
7623        );
7624    }
7625
7626    #[test]
7627    fn test_theme_token_warning_reports_location_and_suggestion() {
7628        let mut parser = HtmlParser::new();
7629        parser
7630            .component_registry_mut()
7631            .register_component(ComponentRegistration::new(
7632                "my-card",
7633                "<div>Card</div>",
7634                ":host {\n  color: var(--colr-brand, #000);\n}".into(),
7635                true,
7636            ))
7637            .expect("register failed");
7638        parser
7639            .parse("test.html", "<my-card></my-card>")
7640            .expect("parse failed");
7641
7642        let analysis = parser.token_analysis();
7643        let theme = webui_tokens::TokenFile {
7644            themes: HashMap::from([(
7645                "light".to_string(),
7646                HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
7647            )]),
7648        };
7649
7650        let warnings = analysis.theme_token_warnings(&theme);
7651        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
7652        let body = warnings[0].body();
7653        assert!(body.contains("my-card.css:2:"), "warning: {body}");
7654        assert!(body.contains("--colr-brand"), "warning: {body}");
7655        assert!(
7656            body.contains("did you mean --color-brand?"),
7657            "warning: {body}"
7658        );
7659    }
7660
7661    #[test]
7662    fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
7663        let mut parser = HtmlParser::new();
7664        let result =
7665            parser
7666                .component_registry_mut()
7667                .register_component(ComponentRegistration::new(
7668                    "my-card",
7669                    "<div>Card</div>",
7670                    Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
7671                    true,
7672                ));
7673
7674        assert!(matches!(result, Err(ParserError::Css(message)) if
7675            message.contains("Unterminated CSS var() call")
7676        ));
7677    }
7678
7679    #[test]
7680    fn test_tokens_merged_from_style_and_components() {
7681        let mut parser = HtmlParser::new();
7682        parser
7683            .component_registry_mut()
7684            .register_component(ComponentRegistration::new(
7685                "my-widget",
7686                "<div>Widget</div>",
7687                Some(".w { padding: var(--spacingM); }"),
7688                true,
7689            ))
7690            .expect("register failed");
7691
7692        let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
7693        parser.parse("test.html", html).expect("parse failed");
7694        let tokens = parser.take_tokens();
7695
7696        assert_eq!(tokens, vec!["spacingM", "textColor"]);
7697    }
7698
7699    #[test]
7700    fn test_tokens_deduplicated() {
7701        let mut parser = HtmlParser::new();
7702        parser
7703            .component_registry_mut()
7704            .register_component(ComponentRegistration::new(
7705                "my-btn",
7706                "<button>B</button>",
7707                Some(".b { color: var(--shared); }"),
7708                true,
7709            ))
7710            .expect("register failed");
7711
7712        let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
7713        parser.parse("test.html", html).expect("parse failed");
7714        let tokens = parser.take_tokens();
7715
7716        assert_eq!(tokens, vec!["shared"]);
7717    }
7718
7719    #[test]
7720    fn test_tokens_empty_when_no_vars() {
7721        let mut parser = HtmlParser::new();
7722        let html = "<div>Hello</div>";
7723        parser.parse("test.html", html).expect("parse failed");
7724        let tokens = parser.take_tokens();
7725
7726        assert!(tokens.is_empty());
7727    }
7728
7729    #[test]
7730    fn test_tokens_exclude_locally_defined_in_component() {
7731        let mut parser = HtmlParser::new();
7732        parser
7733            .component_registry_mut()
7734            .register_component(ComponentRegistration::new(
7735                "my-card",
7736                "<div>Card</div>",
7737                Some(":host { --local: 5px; width: var(--external); }"),
7738                true,
7739            ))
7740            .expect("register failed");
7741
7742        let html = "<my-card></my-card>";
7743        parser.parse("test.html", html).expect("parse failed");
7744        let tokens = parser.take_tokens();
7745
7746        assert_eq!(tokens, vec!["external"]);
7747    }
7748
7749    #[test]
7750    fn test_tokens_exclude_entry_root_definitions() {
7751        let mut parser = HtmlParser::new();
7752        parser
7753            .component_registry_mut()
7754            .register_component(ComponentRegistration::new(
7755                "my-btn",
7756                "<button>B</button>",
7757                Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
7758                true,
7759            ))
7760            .expect("register failed");
7761
7762        // Entry HTML defines --color-primary and --radius-m in :root
7763        // Components use them — they should NOT appear in hoisted tokens
7764        let html = r#"<style>
7765            :root {
7766                --color-primary: #0078d4;
7767                --radius-m: 6px;
7768            }
7769            body { color: var(--color-primary); }
7770        </style>
7771        <my-btn></my-btn>"#;
7772        parser.parse("test.html", html).expect("parse failed");
7773        let tokens = parser.take_tokens();
7774
7775        // Both tokens are defined in entry :root, so neither should be hoisted
7776        assert!(
7777            tokens.is_empty(),
7778            "Tokens defined in entry :root should be excluded: {tokens:?}"
7779        );
7780    }
7781
7782    #[test]
7783    fn test_tokens_entry_defs_exclude_but_external_kept() {
7784        let mut parser = HtmlParser::new();
7785        parser
7786            .component_registry_mut()
7787            .register_component(ComponentRegistration::new(
7788                "my-card",
7789                "<div>Card</div>",
7790                Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
7791                true,
7792            ))
7793            .expect("register failed");
7794
7795        // Entry defines --color-primary but NOT --external-spacing
7796        let html = r#"<style>
7797            :root { --color-primary: #0078d4; }
7798        </style>
7799        <my-card></my-card>"#;
7800        parser.parse("test.html", html).expect("parse failed");
7801        let tokens = parser.take_tokens();
7802
7803        // Only --external-spacing should be hoisted (not defined in entry)
7804        assert_eq!(tokens, vec!["external-spacing"]);
7805    }
7806
7807    // ── Route parsing tests ─────────────────────────────────────────────
7808
7809    #[test]
7810    fn test_parse_simple_route() {
7811        let mut parser = HtmlParser::new();
7812        let html = r#"<route path="/profile" component="profile-page" exact />"#;
7813        parser.parse("test.html", html).expect("parse failed");
7814
7815        // Routes are emitted as Fragment::Route
7816        let records = parser.into_fragment_records();
7817        let frags = &records["test.html"].fragments;
7818        assert_eq!(frags.len(), 1);
7819        match frags[0].fragment.as_ref() {
7820            Some(web_ui_fragment::Fragment::Route(r)) => {
7821                assert_eq!(r.path, "/profile");
7822                assert_eq!(r.fragment_id, "profile-page");
7823                assert!(r.exact);
7824            }
7825            other => panic!("expected Fragment::Route, got {:?}", other),
7826        }
7827    }
7828
7829    #[test]
7830    fn test_parse_route_with_params() {
7831        let mut parser = HtmlParser::new();
7832        let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
7833        parser.parse("test.html", html).expect("parse failed");
7834
7835        // Route is emitted as Fragment::Route with correct path
7836        let records = parser.into_fragment_records();
7837        let frags = &records["test.html"].fragments;
7838        match frags[0].fragment.as_ref() {
7839            Some(web_ui_fragment::Fragment::Route(r)) => {
7840                assert_eq!(r.path, "/profile/:id/view/:section");
7841            }
7842            other => panic!("expected Fragment::Route, got {:?}", other),
7843        }
7844    }
7845
7846    #[test]
7847    fn test_parse_route_requires_component() {
7848        let mut parser = HtmlParser::new();
7849        let html = r#"<route path="/old" />"#;
7850        let result = parser.parse("test.html", html);
7851        assert!(result.is_err());
7852    }
7853
7854    #[test]
7855    fn test_parse_multiple_routes() {
7856        let mut parser = HtmlParser::new();
7857        let html = r#"<route path="/" component="app-layout" />
7858            <route path="/dashboard" component="dash-page" exact />
7859            <route path="/contacts" component="contacts-page" exact />"#;
7860        parser.parse("test.html", html).expect("parse failed");
7861
7862        let records = parser.into_fragment_records();
7863        let frags = &records["test.html"].fragments;
7864        assert_eq!(frags.len(), 3);
7865        // All should be Fragment::Route
7866        for frag in frags {
7867            assert!(matches!(
7868                frag.fragment.as_ref(),
7869                Some(web_ui_fragment::Fragment::Route(_))
7870            ));
7871        }
7872    }
7873
7874    #[test]
7875    fn test_parse_route_requires_component_with_body() {
7876        let mut parser = HtmlParser::new();
7877        let html = r#"<route path="/error">
7878            <div class="error"><h1>Not Found</h1></div>
7879        </route>"#;
7880        let result = parser.parse("test.html", html);
7881        assert!(
7882            result.is_err(),
7883            "Route without component attribute should fail"
7884        );
7885    }
7886
7887    #[test]
7888    fn test_parse_multiple_routes_with_fragments() {
7889        let mut parser = HtmlParser::new();
7890        let html = r#"<route path="/" component="home-page" exact />
7891            <route path="/about" component="about-page" exact />
7892            <route path="/contact/:id" component="contact-page" />"#;
7893        parser.parse("test.html", html).expect("parse failed");
7894
7895        let records = parser.into_fragment_records();
7896        let frags = &records["test.html"].fragments;
7897        assert_eq!(frags.len(), 3);
7898
7899        // Verify individual routes
7900        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
7901            assert_eq!(r.path, "/");
7902        }
7903        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
7904            assert_eq!(r.path, "/about");
7905        }
7906        if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
7907            assert_eq!(r.path, "/contact/:id");
7908        }
7909    }
7910
7911    #[test]
7912    fn test_parse_route_with_query_allowlist() {
7913        let mut parser = HtmlParser::new();
7914        let html =
7915            r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
7916        parser.parse("test.html", html).expect("parse failed");
7917
7918        let records = parser.into_fragment_records();
7919        let frags = &records["test.html"].fragments;
7920        assert_eq!(frags.len(), 1);
7921        match frags[0].fragment.as_ref() {
7922            Some(web_ui_fragment::Fragment::Route(r)) => {
7923                assert_eq!(r.path, "/compose");
7924                assert_eq!(r.fragment_id, "compose-page");
7925                assert!(r.exact);
7926                assert_eq!(r.allowed_query, "action,to,subject");
7927            }
7928            other => panic!("expected Fragment::Route, got {:?}", other),
7929        }
7930    }
7931
7932    #[test]
7933    fn test_parse_route_without_query_has_empty_allowed_query() {
7934        let mut parser = HtmlParser::new();
7935        let html = r#"<route path="/profile" component="profile-page" exact />"#;
7936        parser.parse("test.html", html).expect("parse failed");
7937
7938        let records = parser.into_fragment_records();
7939        let frags = &records["test.html"].fragments;
7940        match frags[0].fragment.as_ref() {
7941            Some(web_ui_fragment::Fragment::Route(r)) => {
7942                assert!(r.allowed_query.is_empty());
7943            }
7944            other => panic!("expected Fragment::Route, got {:?}", other),
7945        }
7946    }
7947
7948    #[test]
7949    fn test_route_body_rejects_malformed_non_route_html() {
7950        let mut parser = HtmlParser::new();
7951        let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
7952        let result = parser.parse("test.html", html);
7953
7954        assert!(matches!(result, Err(ParserError::Template(diag)) if
7955            diag.to_string().contains("unclosed <span> tag")
7956        ));
7957    }
7958
7959    #[test]
7960    fn test_route_body_rejects_malformed_style_css() {
7961        let mut parser = HtmlParser::new();
7962        let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
7963        let result = parser.parse("test.html", html);
7964
7965        assert!(matches!(result, Err(ParserError::Template(diag)) if
7966            diag.error_code() == Some(codes::INVALID_CSS)
7967                && diag.to_string().contains("Unterminated CSS var() call")
7968        ));
7969    }
7970
7971    #[test]
7972    fn test_outlet_not_captured_by_for_loop() {
7973        let mut parser = HtmlParser::new();
7974        let html = r#"<template shadowrootmode="open">
7975  <ul>
7976    <for each="item in items">
7977      <li>{{item.name}}</li>
7978    </for>
7979  </ul>
7980  <main>
7981    <outlet />
7982  </main>
7983</template>"#;
7984        parser.parse("comp.html", html).expect("parse failed");
7985
7986        let records = parser.into_fragment_records();
7987        let frags = &records["comp.html"].fragments;
7988
7989        // The outlet should be a top-level fragment, NOT inside the for-loop's body
7990        let outlet_count = frags
7991            .iter()
7992            .filter(|f| {
7993                matches!(
7994                    f.fragment.as_ref(),
7995                    Some(web_ui_fragment::Fragment::Outlet(_))
7996                )
7997            })
7998            .count();
7999        assert_eq!(
8000            outlet_count, 1,
8001            "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
8002        );
8003
8004        // Verify outlet comes AFTER the raw "</ul>" text
8005        let outlet_idx = frags
8006            .iter()
8007            .position(|f| {
8008                matches!(
8009                    f.fragment.as_ref(),
8010                    Some(web_ui_fragment::Fragment::Outlet(_))
8011                )
8012            })
8013            .expect("no outlet found");
8014        let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
8015            Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
8016            _ => false,
8017        });
8018        if let Some(ul_idx) = close_ul_idx {
8019            assert!(
8020                outlet_idx > ul_idx,
8021                "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
8022            );
8023        }
8024    }
8025
8026    #[test]
8027    fn test_outlet_position_after_for_not_inside() {
8028        let mut parser = HtmlParser::new();
8029        let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
8030        parser.parse("test.html", html).expect("parse failed");
8031
8032        let records = parser.into_fragment_records();
8033        let frags = &records["test.html"].fragments;
8034
8035        // Outlet should be its own fragment at top level
8036        let has_outlet = frags.iter().any(|f| {
8037            matches!(
8038                f.fragment.as_ref(),
8039                Some(web_ui_fragment::Fragment::Outlet(_))
8040            )
8041        });
8042        assert!(
8043            has_outlet,
8044            "outlet should be in top-level fragments: {frags:?}"
8045        );
8046
8047        // The for-loop body should NOT contain the outlet
8048        let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
8049            Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
8050            _ => None,
8051        });
8052        if let Some(id) = for_id {
8053            let for_frags = &records[&id].fragments;
8054            let outlet_in_for = for_frags.iter().any(|f| {
8055                matches!(
8056                    f.fragment.as_ref(),
8057                    Some(web_ui_fragment::Fragment::Outlet(_))
8058                )
8059            });
8060            assert!(
8061                !outlet_in_for,
8062                "outlet should NOT be inside for-loop body: {for_frags:?}"
8063            );
8064        }
8065    }
8066
8067    #[test]
8068    fn test_style_element_with_handlebars_signal_comment_emits_signal() {
8069        let mut parser = HtmlParser::new();
8070        let html = r#"<html><head><style>
8071:root {
8072    /*{{{tokens.light}}}*/
8073}
8074</style></head><body></body></html>"#;
8075
8076        let result = parser.parse("test.html", html);
8077        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8078        let fragment_records = parser.into_fragment_records();
8079
8080        assert_stream!(
8081            fragment_records,
8082            "test.html",
8083            [
8084                raw("<html><head>"),
8085                structural_matcher("head_start"),
8086                raw("<style>\n:root {\n    "),
8087                signal_raw("tokens.light"),
8088                raw("\n}\n</style>"),
8089                structural_matcher("head_end"),
8090                raw("</head><body>"),
8091                structural_matcher("body_start"),
8092                structural_matcher("body_end"),
8093                raw("</body></html>"),
8094            ]
8095        );
8096    }
8097
8098    #[test]
8099    fn test_style_comment_signal_with_spaces_emits_signal() {
8100        let mut parser = HtmlParser::new();
8101        let html = r#"<html><head><style>
8102:root {
8103    /* {{{tokens.light}}} */
8104}
8105</style></head><body></body></html>"#;
8106
8107        let result = parser.parse("test.html", html);
8108        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8109        let fragment_records = parser.into_fragment_records();
8110
8111        assert_stream!(
8112            fragment_records,
8113            "test.html",
8114            [
8115                raw("<html><head>"),
8116                structural_matcher("head_start"),
8117                raw("<style>\n:root {\n    "),
8118                signal_raw("tokens.light"),
8119                raw("\n}\n</style>"),
8120                structural_matcher("head_end"),
8121                raw("</head><body>"),
8122                structural_matcher("body_start"),
8123                structural_matcher("body_end"),
8124                raw("</body></html>"),
8125            ]
8126        );
8127    }
8128
8129    #[test]
8130    fn test_style_comment_signal_double_brace_emits_signal() {
8131        let mut parser = HtmlParser::new();
8132        let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
8133
8134        let result = parser.parse("test.html", html);
8135        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8136        let fragment_records = parser.into_fragment_records();
8137
8138        assert_stream!(
8139            fragment_records,
8140            "test.html",
8141            [
8142                raw("<html><head>"),
8143                structural_matcher("head_start"),
8144                raw("<style>"),
8145                signal("themeCss"),
8146                raw("</style>"),
8147                structural_matcher("head_end"),
8148                raw("</head><body>"),
8149                structural_matcher("body_start"),
8150                structural_matcher("body_end"),
8151                raw("</body></html>"),
8152            ]
8153        );
8154    }
8155
8156    #[test]
8157    fn test_style_comment_with_extra_text_is_stripped() {
8158        let mut parser = HtmlParser::new();
8159        let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
8160
8161        let result = parser.parse("test.html", html);
8162        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8163        let fragment_records = parser.into_fragment_records();
8164
8165        assert_stream!(
8166            fragment_records,
8167            "test.html",
8168            [
8169                raw("<html><head>"),
8170                structural_matcher("head_start"),
8171                raw("<style></style>"),
8172                structural_matcher("head_end"),
8173                raw("</head><body>"),
8174                structural_matcher("body_start"),
8175                structural_matcher("body_end"),
8176                raw("</body></html>"),
8177            ]
8178        );
8179    }
8180
8181    #[test]
8182    fn test_style_comment_with_multiple_signals_is_stripped() {
8183        let mut parser = HtmlParser::new();
8184        let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
8185
8186        let result = parser.parse("test.html", html);
8187        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8188        let fragment_records = parser.into_fragment_records();
8189
8190        assert_stream!(
8191            fragment_records,
8192            "test.html",
8193            [
8194                raw("<html><head>"),
8195                structural_matcher("head_start"),
8196                raw("<style></style>"),
8197                structural_matcher("head_end"),
8198                raw("</head><body>"),
8199                structural_matcher("body_start"),
8200                structural_matcher("body_end"),
8201                raw("</body></html>"),
8202            ]
8203        );
8204    }
8205
8206    #[test]
8207    fn test_style_bare_handlebars_stays_raw() {
8208        let mut parser = HtmlParser::new();
8209        // Handlebars outside a CSS comment must NOT be parsed as signals.
8210        let html =
8211            "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
8212
8213        let result = parser.parse("test.html", html);
8214        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8215        let fragment_records = parser.into_fragment_records();
8216
8217        assert_stream!(
8218            fragment_records,
8219            "test.html",
8220            [
8221                raw("<html><head>"),
8222                structural_matcher("head_start"),
8223                raw("<style>body { color: {{textColor}}; }</style>"),
8224                structural_matcher("head_end"),
8225                raw("</head><body>"),
8226                structural_matcher("body_start"),
8227                structural_matcher("body_end"),
8228                raw("</body></html>"),
8229            ]
8230        );
8231    }
8232
8233    #[test]
8234    fn test_style_mixed_css_and_comment_signal_emits_signal() {
8235        let mut parser = HtmlParser::new();
8236        let html = r#"<html><head><style>
8237  .a { color: red; }
8238  /*{{themeCss}}*/
8239  .b { color: blue; }
8240</style></head><body></body></html>"#;
8241
8242        let result = parser.parse("test.html", html);
8243        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8244        let fragment_records = parser.into_fragment_records();
8245
8246        assert_stream!(
8247            fragment_records,
8248            "test.html",
8249            [
8250                raw("<html><head>"),
8251                structural_matcher("head_start"),
8252                raw("<style>\n  .a { color: red; }\n  "),
8253                signal("themeCss"),
8254                raw("\n  .b { color: blue; }\n</style>"),
8255                structural_matcher("head_end"),
8256                raw("</head><body>"),
8257                structural_matcher("body_start"),
8258                structural_matcher("body_end"),
8259                raw("</body></html>"),
8260            ]
8261        );
8262    }
8263
8264    #[test]
8265    fn test_style_legal_comment_preserved_inline_by_default() {
8266        let mut parser = HtmlParser::new();
8267        let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
8268
8269        parser.parse("test.html", html).expect("parse failed");
8270        let fragment_records = parser.into_fragment_records();
8271
8272        assert_stream!(
8273            fragment_records,
8274            "test.html",
8275            [
8276                raw("<html><head>"),
8277                structural_matcher("head_start"),
8278                raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
8279                structural_matcher("head_end"),
8280                raw("</head><body>"),
8281                structural_matcher("body_start"),
8282                structural_matcher("body_end"),
8283                raw("</body></html>"),
8284            ]
8285        );
8286    }
8287
8288    #[test]
8289    fn test_style_legal_comments_none_strips_legal_comment() {
8290        let mut parser = HtmlParser::with_options(ParserOptions {
8291            legal_comments: LegalComments::None,
8292            ..ParserOptions::default()
8293        });
8294        let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
8295
8296        parser.parse("test.html", html).expect("parse failed");
8297        let fragment_records = parser.into_fragment_records();
8298
8299        assert_stream!(
8300            fragment_records,
8301            "test.html",
8302            [
8303                raw("<html><head>"),
8304                structural_matcher("head_start"),
8305                raw("<style> .x { color: red; }</style>"),
8306                structural_matcher("head_end"),
8307                raw("</head><body>"),
8308                structural_matcher("body_start"),
8309                structural_matcher("body_end"),
8310                raw("</body></html>"),
8311            ]
8312        );
8313    }
8314
8315    #[test]
8316    fn test_style_element_plain_css_unchanged() {
8317        let mut parser = HtmlParser::new();
8318        let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
8319
8320        let result = parser.parse("test.html", html);
8321        assert!(result.is_ok(), "Parse error: {:?}", result.err());
8322        let fragment_records = parser.into_fragment_records();
8323
8324        // Plain CSS with no handlebars should remain as a single raw fragment.
8325        assert_stream!(
8326            fragment_records,
8327            "test.html",
8328            [
8329                raw("<html><head>"),
8330                structural_matcher("head_start"),
8331                raw("<style>body { margin: 0; }</style>"),
8332                structural_matcher("head_end"),
8333                raw("</head><body>"),
8334                structural_matcher("body_start"),
8335                structural_matcher("body_end"),
8336                raw("</body></html>"),
8337            ]
8338        );
8339    }
8340
8341    #[test]
8342    fn test_unclosed_style_element_returns_error() {
8343        let mut parser = HtmlParser::new();
8344        let result = parser.parse("index.html", "<style>.x { color: red; ");
8345        assert!(matches!(result, Err(ParserError::Template(diag)) if
8346            diag.to_string().contains("unclosed <style> tag")
8347        ));
8348    }
8349
8350    #[test]
8351    fn test_text_with_binding_after_void_element() {
8352        // Text nodes containing {{binding}} after void elements like <br>
8353        // must be preserved. Regression test for content being dropped.
8354        let (fragments, _) = parse_and_get_fragments(
8355            "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
8356        );
8357        assert_fragments!(
8358            fragments,
8359            [
8360                raw("Part A: "),
8361                signal("value1"),
8362                raw("<button>Click</button><br>Part B: "),
8363                signal("value2"),
8364                raw("<button>Click2</button>"),
8365            ]
8366        );
8367    }
8368}