Skip to main content

webui_parser/
lib.rs

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