mod asset_filename;
mod comment_policy;
mod component_policy;
mod component_registry;
mod condition_parser;
mod css_light;
mod css_link;
mod css_parser;
mod css_scan;
mod diagnostic;
mod error;
mod handlebars_parser;
mod html_parser;
mod outlet_warnings;
pub mod plugin;
mod route_parser;
mod scoped_visits;
mod suggest;
pub use asset_filename::{
AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
};
pub use component_registry::{Component, ComponentRegistration, ComponentRegistry};
pub use condition_parser::ConditionParser;
pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
pub use css_parser::CssParser;
pub use diagnostic::{codes, Diagnostic, Severity};
pub use error::{ParserError, Result};
pub use handlebars_parser::HandlebarsParser;
pub use webui_tokens::CssFallbackChain;
use crate::component_policy::{
parse_component_render_policy, ComponentRenderPolicy, HYDRATE_ATTR as COMPONENT_HYDRATE_ATTR,
RENDER_ATTR as COMPONENT_RENDER_ATTR,
RESERVE_BLOCK_SIZE_ATTR as COMPONENT_RESERVE_BLOCK_SIZE_ATTR,
};
use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
use crate::plugin::{
AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
ElementStartContext, FragmentContext, ParserPlugin, ParserPluginArtifacts,
};
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use webui_protocol::{
web_ui_fragment, web_ui_fragment::Fragment, BoundaryPhase, ConditionExpr, FragmentList,
WebUIFragment, WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentBoundary,
WebUiFragmentRoute,
};
const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
const MAX_TEMPLATE_DEPTH: usize = 512;
const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
fn structural_signal(value: impl AsRef<str>) -> WebUIFragment {
WebUIFragment::signal(
format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
true,
)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum FosterContext {
Transparent,
Hostile,
Barrier,
}
#[inline]
fn foster_context_of(name: &str) -> FosterContext {
match name.len() {
2 => {
if name.eq_ignore_ascii_case("tr") {
FosterContext::Hostile
} else if name.eq_ignore_ascii_case("td") || name.eq_ignore_ascii_case("th") {
FosterContext::Barrier
} else {
FosterContext::Transparent
}
}
5 => {
if name.eq_ignore_ascii_case("table")
|| name.eq_ignore_ascii_case("tbody")
|| name.eq_ignore_ascii_case("thead")
|| name.eq_ignore_ascii_case("tfoot")
{
FosterContext::Hostile
} else {
FosterContext::Transparent
}
}
6 => {
if name.eq_ignore_ascii_case("select") {
FosterContext::Hostile
} else {
FosterContext::Transparent
}
}
7 => {
if name.eq_ignore_ascii_case("caption") {
FosterContext::Barrier
} else {
FosterContext::Transparent
}
}
8 => {
if name.eq_ignore_ascii_case("optgroup") || name.eq_ignore_ascii_case("colgroup") {
FosterContext::Hostile
} else {
FosterContext::Transparent
}
}
_ => FosterContext::Transparent,
}
}
#[inline]
fn boundary_parent_scope(name: &str) -> Option<&'static str> {
match name.len() {
3 if name.eq_ignore_ascii_case("xmp") => Some("<xmp> raw-text content"),
5 if name.eq_ignore_ascii_case("title") => Some("<title> text content"),
6 if name.eq_ignore_ascii_case("iframe") => Some("<iframe> raw-text content"),
6 if name.eq_ignore_ascii_case("script") => Some("<script> raw-text content"),
7 if name.eq_ignore_ascii_case("noembed") => Some("<noembed> raw-text content"),
8 if name.eq_ignore_ascii_case("template") => Some("<template> inert content"),
8 if name.eq_ignore_ascii_case("textarea") => Some("<textarea> text content"),
8 if name.eq_ignore_ascii_case("noframes") => Some("<noframes> raw-text content"),
8 if name.eq_ignore_ascii_case("noscript") => Some("<noscript> inert content"),
9 if name.eq_ignore_ascii_case("plaintext") => Some("<plaintext> text content"),
_ => None,
}
}
#[inline]
fn is_html_text_only_scope(scope: Option<&str>) -> bool {
matches!(
scope,
Some(
"<xmp> raw-text content"
| "<title> text content"
| "<iframe> raw-text content"
| "<script> raw-text content"
| "<noembed> raw-text content"
| "<textarea> text content"
| "<noframes> raw-text content"
| "<noscript> inert content"
| "<plaintext> text content"
)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum CssStrategy {
#[default]
Link,
Style,
Module,
}
impl std::fmt::Display for CssStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CssStrategy::Link => write!(f, "link"),
CssStrategy::Style => write!(f, "style"),
CssStrategy::Module => write!(f, "module"),
}
}
}
impl std::str::FromStr for CssStrategy {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"link" => Ok(CssStrategy::Link),
"style" => Ok(CssStrategy::Style),
"module" => Ok(CssStrategy::Module),
other => Err(format!(
"Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum DomStrategy {
#[default]
Shadow,
Light,
}
impl std::fmt::Display for DomStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Shadow => write!(f, "shadow"),
Self::Light => write!(f, "light"),
}
}
}
impl std::str::FromStr for DomStrategy {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
match value {
"shadow" => Ok(Self::Shadow),
"light" => Ok(Self::Light),
other => Err(format!(
"Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum LegalComments {
None,
#[default]
Inline,
}
impl std::fmt::Display for LegalComments {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LegalComments::None => write!(f, "none"),
LegalComments::Inline => write!(f, "inline"),
}
}
}
impl std::str::FromStr for LegalComments {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"none" => Ok(LegalComments::None),
"inline" => Ok(LegalComments::Inline),
other => Err(format!(
"Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
)),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ParserOptions {
pub css_strategy: CssStrategy,
pub dom_strategy: DomStrategy,
pub css_link_options: CssLinkOptions,
pub legal_comments: LegalComments,
}
impl ParserOptions {
pub fn try_new(
css_strategy: CssStrategy,
dom_strategy: DomStrategy,
css_file_name_template: &str,
css_public_base: Option<&str>,
legal_comments: LegalComments,
) -> Result<Self> {
let css_link_options = if css_strategy == CssStrategy::Link {
CssLinkOptions::try_new(
css_file_name_template.to_string(),
css_public_base.map(std::string::ToString::to_string),
)?
} else {
CssLinkOptions::default()
};
Ok(Self {
css_strategy,
dom_strategy,
css_link_options,
legal_comments,
})
}
}
impl From<DomStrategy> for ParserOptions {
fn from(dom_strategy: DomStrategy) -> Self {
Self {
dom_strategy,
..Self::default()
}
}
}
impl From<CssStrategy> for ParserOptions {
fn from(css_strategy: CssStrategy) -> Self {
Self {
css_strategy,
..Self::default()
}
}
}
impl From<(CssStrategy, DomStrategy)> for ParserOptions {
fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
Self {
css_strategy,
dom_strategy,
..Self::default()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum Plugin {
#[cfg_attr(feature = "cli", value(name = "fast"))]
Fast,
#[cfg_attr(feature = "cli", value(name = "fast-v2"))]
FastV2,
#[cfg_attr(feature = "cli", value(name = "fast-v3"))]
FastV3,
#[cfg_attr(feature = "cli", value(name = "webui"))]
WebUI,
}
impl std::fmt::Display for Plugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Plugin::Fast => write!(f, "fast"),
Plugin::FastV2 => write!(f, "fast-v2"),
Plugin::FastV3 => write!(f, "fast-v3"),
Plugin::WebUI => write!(f, "webui"),
}
}
}
impl std::str::FromStr for Plugin {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"fast" => Ok(Plugin::Fast),
"fast-v2" => Ok(Plugin::FastV2),
"fast-v3" => Ok(Plugin::FastV3),
"webui" => Ok(Plugin::WebUI),
other => Err(format!(
"Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
)),
}
}
}
struct FragmentIdCounter {
counters: HashMap<String, usize>,
}
impl FragmentIdCounter {
fn new() -> Self {
Self {
counters: HashMap::new(),
}
}
fn next_id(&mut self, prefix: &str) -> String {
let count = self.counters.entry(prefix.to_string()).or_insert(0);
*count += 1;
format!("{}-{}", prefix, count)
}
fn has_generated(&self, id: &str) -> bool {
let Some((prefix, number)) = id.rsplit_once('-') else {
return false;
};
self.counters.get(prefix).is_some_and(|count| {
number
.parse::<usize>()
.is_ok_and(|number| number > 0 && number <= *count)
})
}
}
#[derive(Default)]
struct NamedForLoops {
entries: HashMap<String, NamedFor>,
}
struct NamedFor {
item: String,
offset: usize,
defined: bool,
}
impl NamedForLoops {
fn register(&mut self, owner: &str, id: &str, item: &str, site: (bool, usize)) -> Result<()> {
let (definition, offset) = site;
if let Some(entry) = self.entries.get_mut(id) {
if definition && entry.defined {
return Err(named_for_error(
owner,
codes::DUPLICATE_FOR_ID,
"duplicate named <for> definition",
id,
"define the body once per file and use a self-closing <for id=\"...\" each=\"...\" /> to reuse it",
)
.into());
}
if entry.item != item {
return Err(named_for_error(
owner,
codes::INCOMPATIBLE_FOR_ITEM,
"inconsistent item variable for named <for>",
id,
&format!(
"use each=\"{} in collection\" at every reference to this id",
entry.item
),
)
.into());
}
entry.defined |= definition;
} else {
self.entries.insert(
id.to_string(),
NamedFor {
item: item.to_string(),
offset,
defined: definition,
},
);
}
Ok(())
}
fn validate(&self, owner: &str, source: &str) -> Result<()> {
let missing = self
.entries
.iter()
.filter(|(_, entry)| !entry.defined)
.min_by_key(|(_, entry)| entry.offset);
if let Some((id, entry)) = missing {
return Err(named_for_error(
owner,
codes::UNKNOWN_FOR_ID,
"unknown named <for> definition",
id,
"add a <for> with this id and a body in the same file; definitions in other files are not visible",
)
.at_offset(source, entry.offset)
.into());
}
Ok(())
}
}
fn parse_for_each<'a>(owner: &str, each: &'a str) -> Result<(&'a str, &'a str)> {
let mut parts = each.split_whitespace();
let (Some(item), Some("in"), Some(collection), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(invalid_for_each(owner, each, codes::INVALID_FOR_EACH));
};
let allowed = |value: &str| {
value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' || byte == b'.'
})
};
if !allowed(item) || !allowed(collection) {
return Err(invalid_for_each(owner, each, codes::INVALID_FOR_IDENTIFIER));
}
Ok((item, collection))
}
fn validate_for_id<'a>(owner: &str, tag: &html::Tag<'a>) -> Result<Option<&'a str>> {
let mut id = None;
let mut id_count = 0;
for attr in tag.attrs() {
match attr.name {
"template" => return Err(unsupported_for_template(owner)),
"id" => {
id = Some(attr.value.unwrap_or_default());
id_count += 1;
}
_ => {}
}
}
let Some(id) = id else {
return Ok(None);
};
if id.is_empty()
|| !id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
|| id_count != 1
{
return Err(named_for_error(
owner,
codes::INVALID_FOR_ID,
"invalid <for> id",
id,
"use one non-empty static id containing letters, digits, '_' or '-'",
)
.into());
}
Ok(Some(id))
}
fn strip_condition_braces(value: &str) -> &str {
let value = value.trim();
value
.strip_prefix("{{")
.and_then(|value| value.strip_suffix("}}"))
.map_or(value, str::trim)
}
#[cold]
#[inline(never)]
fn locate_for_error(error: ParserError, element: &Element<'_>) -> ParserError {
match error {
ParserError::Template(diagnostic) => (*diagnostic)
.at_offset(element.source(), element.start)
.into(),
other => other,
}
}
#[cold]
#[inline(never)]
fn invalid_for_each(owner: &str, each: &str, code: &'static str) -> ParserError {
Diagnostic::error("invalid <for> each expression")
.code(code)
.component(owner)
.element("for")
.snippet(format!("each=\"{each}\""))
.help("use each=\"item in collection\" without braces; item and collection names may use only letters, digits, '_', '-', and '.'")
.into()
}
#[cold]
#[inline(never)]
fn unsupported_for_template(owner: &str) -> ParserError {
Diagnostic::error("unsupported <for> template attribute")
.code(codes::INVALID_FOR_ID)
.component(owner)
.element("for")
.snippet("template")
.help("replace template with id on the loop definition and every reference; named loops are local to their file")
.into()
}
#[cold]
#[inline(never)]
fn named_for_error(
owner: &str,
code: &'static str,
title: &str,
id: &str,
help: &str,
) -> Diagnostic {
Diagnostic::error(title)
.code(code)
.component(owner)
.element("for")
.snippet(format!("id=\"{id}\""))
.help(help)
}
struct ParseContext {
fragments: Vec<WebUIFragment>,
raw_buffer: String,
}
struct PendingBoundary {
declaration_id: u32,
}
struct BoundaryGraphEdge {
target: String,
}
struct RepeatSite<'a> {
owner: &'a str,
item: &'a str,
collection: &'a str,
}
struct RepeatBoundaryViolation {
repeat_owner: String,
each: String,
boundary_owner: String,
boundary_name: String,
}
fn push_route_records<'a>(targets: &mut Vec<&'a str>, root: &'a WebUiFragmentRoute) {
let mut pending = vec![root];
while let Some(route) = pending.pop() {
targets.push(route.fragment_id.as_str());
targets.push(route.content_fragment_id.as_str());
targets.push(route.pending_component.as_str());
targets.push(route.error_component.as_str());
pending.extend(route.children.iter());
}
}
fn push_child_records<'a>(list: &'a FragmentList, targets: &mut Vec<&'a str>) {
for fragment in &list.fragments {
match fragment.fragment.as_ref() {
Some(Fragment::Component(component)) => targets.push(&component.fragment_id),
Some(Fragment::IfCond(if_cond)) => targets.push(&if_cond.fragment_id),
Some(Fragment::ForLoop(for_loop)) => targets.push(&for_loop.fragment_id),
Some(Fragment::Route(route)) => push_route_records(targets, route),
_ => {}
}
}
}
#[derive(Default)]
struct FragmentCssTokens {
definitions: Vec<String>,
fallback_chains: Vec<CssFallbackChain>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CssTokenAnalysis {
pub protocol_tokens: Vec<String>,
pub fallback_chains: Vec<CssFallbackChain>,
pub(crate) token_sites: HashMap<String, TokenSite>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TokenSite {
owner: String,
position: Option<(usize, usize)>,
snippet: Option<String>,
}
enum ParseOp<'a> {
Parse {
range: Range<usize>,
depth: usize,
},
EmitClose(&'a str),
EndComponent(&'a str),
EndHead(&'a str),
EndBody(&'a str),
CompleteBoundary {
boundary: PendingBoundary,
},
RestoreFosterDepth(u32),
RestoreBoundaryParentScope(Option<&'static str>),
CompleteFor {
parent: ParseContext,
item: String,
collection: String,
fragment_id: String,
named_definition: bool,
previous_for_depth: usize,
},
CompleteIf {
parent: ParseContext,
condition: ConditionExpr,
fragment_id: String,
},
}
enum TokenGraphOp<'a> {
EnterFragment(&'a str),
ExitFragment(&'a str),
EnterComponent(&'a str),
EnterRoute(&'a WebUiFragmentRoute),
ExitDefinitions(&'a [String]),
}
impl Default for HtmlParser {
fn default() -> Self {
Self::new()
}
}
pub struct HtmlParser {
css_parser: CssParser,
id_counter: FragmentIdCounter,
condition_parser: ConditionParser,
handlebars_parser: HandlebarsParser,
component_registry: ComponentRegistry,
fragment_records: WebUIFragmentRecords,
warnings: Vec<Diagnostic>,
raw_buffer: String,
options: ParserOptions,
plugin: Option<Box<dyn ParserPlugin>>,
component_processing: ComponentProcessing,
component_dom_analyses: HashMap<String, ComponentDomAnalysis>,
token_roots: Vec<String>,
fragment_css_tokens: HashMap<String, FragmentCssTokens>,
in_progress_fragments: HashSet<String>,
current_fragment_id: String,
loop_owner_indices: HashMap<String, usize>,
named_for_loops: NamedForLoops,
named_for_fragment_ids: HashSet<String>,
next_boundary_declaration_id: u32,
boundary_names_by_owner: HashMap<String, HashSet<String>>,
in_boundary: bool,
boundary_ancestor_depth: usize,
body_depth: usize,
for_depth: usize,
route_depth: usize,
foster_context_depth: u32,
boundary_parent_scope: Option<&'static str>,
module_entry_srcs: Vec<String>,
}
struct BuiltComponentTemplate {
ssr: String,
artifact: Option<String>,
uses_shadow_dom: bool,
style: Option<OwnedComponentStyle>,
}
enum OwnedComponentStyle {
Link(String),
Inline(String),
Adopted(String),
}
#[derive(Clone, Copy)]
struct ComponentStyleInjection<'a> {
css_snippet: Option<&'a str>,
adopted_specifier: Option<&'a str>,
}
#[derive(Clone, Copy)]
struct ComponentTemplateMode {
preserve_runtime_attrs: bool,
policy_wrapper: bool,
styles_at_end: bool,
}
impl BuiltComponentTemplate {
fn artifact(&self) -> &str {
self.artifact.as_deref().unwrap_or(&self.ssr)
}
fn plugin_context<'a>(&'a self, component: &'a Component) -> ComponentBuildContext<'a> {
ComponentBuildContext {
component,
template: self.artifact(),
uses_shadow_dom: self.uses_shadow_dom,
style: self.style.as_ref().map(|style| match style {
OwnedComponentStyle::Link(href) => plugin::ComponentStyleDelivery::Link { href },
OwnedComponentStyle::Inline(css) => plugin::ComponentStyleDelivery::Inline { css },
OwnedComponentStyle::Adopted(specifier) => {
plugin::ComponentStyleDelivery::Adopted { specifier }
}
}),
}
}
}
#[derive(Debug, Clone, Copy)]
struct ComponentDomAnalysis {
uses_shadow_dom: bool,
authored_shadow_root: Option<(usize, usize)>,
authored_light_root: Option<(usize, usize)>,
}
struct ShadowRootModeOccurrence<'a> {
element_start: usize,
element_end: usize,
element_name: &'a str,
has_closing_tag: bool,
dynamically_bound: bool,
attr_offset: usize,
value: Option<&'a str>,
}
#[cfg(test)]
fn analyze_component_dom(
tag_name: &str,
source: &str,
fallback: DomStrategy,
) -> Result<ComponentDomAnalysis> {
analyze_component_dom_with_diagnostic_source(tag_name, source, fallback, None)
}
fn analyze_component_dom_with_diagnostic_source(
tag_name: &str,
source: &str,
fallback: DomStrategy,
diagnostic_source: Option<&str>,
) -> Result<ComponentDomAnalysis> {
let mut ranges = Vec::with_capacity(8);
ranges.push((0..source.len(), true));
let mut top_level_element_count = 0usize;
let mut top_level_root_start = 0usize;
let mut has_top_level_content = false;
let mut bare_template_root = None;
let mut shadow_mode = None;
let mut slot_offset = None;
while let Some((range, top_level)) = ranges.pop() {
for event in Walker::new_range(source, range.start, range.end) {
match event {
Event::Text(text) if top_level && !text.trim().is_empty() => {
has_top_level_content = true;
}
Event::Declaration(_) if top_level => {
has_top_level_content = true;
}
Event::Element(element) => {
if top_level {
top_level_element_count += 1;
top_level_root_start = element.start;
bare_template_root = (element.name().eq_ignore_ascii_case("template")
&& !element.self_closing()
&& element.attrs().next().is_none())
.then_some((element.content_start, element.content_end));
}
for attr in element.attrs() {
let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
if attr_name.eq_ignore_ascii_case("shadowrootmode") {
if shadow_mode.is_some() {
return Err(invalid_shadow_root_mode_error(
tag_name,
DiagnosticSite::new(
source,
diagnostic_source,
element.start + attr.raw_range.start,
),
"a component may declare only one `shadowrootmode`",
"keep one `shadowrootmode=\"open\"` attribute on the sole top-level `<template>`",
));
}
shadow_mode = Some(ShadowRootModeOccurrence {
element_start: element.start,
element_end: element.close_end(),
element_name: element.name(),
has_closing_tag: !element.self_closing()
&& element.close_end() > element.content_end(),
dynamically_bound: attr.name != attr_name,
attr_offset: element.start + attr.raw_range.start,
value: attr.value,
});
}
}
if slot_offset.is_none() && element.name().eq_ignore_ascii_case("slot") {
slot_offset = Some(element.start);
}
let name = element.name();
let raw_text = name.eq_ignore_ascii_case("script")
|| name.eq_ignore_ascii_case("style")
|| name.eq_ignore_ascii_case("textarea")
|| name.eq_ignore_ascii_case("title");
if element.content_end() > element.inner().start && !raw_text {
ranges.push((element.inner(), false));
}
}
Event::Text(_)
| Event::Comment(_)
| Event::Declaration(_)
| Event::ClosingTag(_) => {}
}
}
}
let authored_shadow_root = match shadow_mode {
Some(mode) => {
let value = mode.value;
if mode.dynamically_bound {
return Err(invalid_shadow_root_mode_error(
tag_name,
DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
"`shadowrootmode` must be a static attribute",
"replace the binding with `shadowrootmode=\"open\"` on the sole top-level `<template>`",
));
}
if value.is_some_and(|value| value.eq_ignore_ascii_case("closed")) {
return Err(invalid_shadow_root_mode_error(
tag_name,
DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
"`shadowrootmode=\"closed\"` is not supported",
"use `shadowrootmode=\"open\"`; WebUI requires an open root for hydration and style delivery",
));
}
if !value.is_some_and(|value| value.eq_ignore_ascii_case("open")) {
return Err(invalid_shadow_root_mode_error(
tag_name,
DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
"`shadowrootmode` must be `open`",
"set the attribute to `shadowrootmode=\"open\"`",
));
}
if top_level_element_count != 1
|| has_top_level_content
|| mode.element_start != top_level_root_start
|| !mode.element_name.eq_ignore_ascii_case("template")
|| !mode.has_closing_tag
{
return Err(invalid_shadow_root_mode_error(
tag_name,
DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
"the declarative Shadow DOM wrapper must be the sole top-level element",
"wrap the complete component in one top-level `<template shadowrootmode=\"open\">`",
));
}
Some((mode.element_start, mode.element_end))
}
None => None,
};
let authored_light_root =
if authored_shadow_root.is_none() && top_level_element_count == 1 && !has_top_level_content
{
bare_template_root
} else {
None
};
let uses_shadow_dom = authored_shadow_root.is_some()
|| (authored_light_root.is_none() && fallback == DomStrategy::Shadow);
if !uses_shadow_dom {
if let Some(offset) = slot_offset {
return Err(light_dom_slot_error(
tag_name,
DiagnosticSite::new(source, diagnostic_source, offset),
));
}
}
Ok(ComponentDomAnalysis {
uses_shadow_dom,
authored_shadow_root,
authored_light_root,
})
}
#[cold]
#[inline(never)]
fn invalid_shadow_root_mode_error(
tag_name: &str,
site: DiagnosticSite<'_>,
title: &str,
help: &str,
) -> ParserError {
let (source, offset) = site.resolve(DiagnosticTarget::Attribute("shadowrootmode"));
Diagnostic::error(title)
.code(codes::INVALID_SHADOW_ROOT_MODE)
.component(tag_name)
.at_offset(source, offset)
.snippet(source_line_snippet(source, offset))
.help(help)
.into()
}
#[cold]
#[inline(never)]
fn light_dom_slot_error(tag_name: &str, site: DiagnosticSite<'_>) -> ParserError {
let (source, offset) = site.resolve(DiagnosticTarget::Element("slot"));
Diagnostic::error("native `<slot>` projection requires Shadow DOM")
.code(codes::LIGHT_DOM_SLOT)
.component(tag_name)
.element("slot")
.at_offset(source, offset)
.snippet(source_line_snippet(source, offset))
.help(
"wrap the complete component template in `<template shadowrootmode=\"open\">` to use `<slot>`",
)
.into()
}
#[derive(Clone, Copy)]
struct DiagnosticSite<'a> {
source: &'a str,
authored_source: Option<&'a str>,
offset: usize,
}
impl<'a> DiagnosticSite<'a> {
fn new(source: &'a str, authored_source: Option<&'a str>, offset: usize) -> Self {
Self {
source,
authored_source,
offset,
}
}
fn resolve(self, target: DiagnosticTarget) -> (&'a str, usize) {
let Some(authored_source) = self.authored_source else {
return (self.source, self.offset);
};
let transformed_offsets = diagnostic_target_offsets(self.source, target);
let occurrence = transformed_offsets
.iter()
.position(|offset| *offset == self.offset)
.unwrap_or(0);
let authored_offsets = diagnostic_target_offsets(authored_source, target);
authored_offsets
.get(occurrence)
.copied()
.map_or((self.source, self.offset), |offset| {
(authored_source, offset)
})
}
}
#[derive(Clone, Copy)]
enum DiagnosticTarget {
Element(&'static str),
Attribute(&'static str),
}
#[cold]
#[inline(never)]
fn diagnostic_target_offsets(source: &str, target: DiagnosticTarget) -> Vec<usize> {
let mut offsets = Vec::new();
let mut ranges = Vec::with_capacity(4);
ranges.push(0..source.len());
while let Some(range) = ranges.pop() {
for event in Walker::new_range(source, range.start, range.end) {
let Event::Element(element) = event else {
continue;
};
match target {
DiagnosticTarget::Element(name) if element.name().eq_ignore_ascii_case(name) => {
offsets.push(element.start);
}
DiagnosticTarget::Attribute(name) => {
for attr in element.attrs() {
let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
if attr_name.eq_ignore_ascii_case(name) {
offsets.push(element.start + attr.raw_range.start);
}
}
}
DiagnosticTarget::Element(_) => {}
}
let name = element.name();
let raw_text = name.eq_ignore_ascii_case("script")
|| name.eq_ignore_ascii_case("style")
|| name.eq_ignore_ascii_case("textarea")
|| name.eq_ignore_ascii_case("title");
if element.content_end() > element.inner().start && !raw_text {
ranges.push(element.inner());
}
}
}
offsets.sort_unstable();
offsets
}
struct ComponentTemplateInput<'a> {
tag_name: &'a str,
html: &'a str,
artifact_html: Option<&'a str>,
authored_html: Option<&'a str>,
css_content: Option<&'a str>,
artifact_needed: bool,
}
fn add_token_definitions<'a>(
definitions: &'a [String],
available_counts: &mut HashMap<&'a str, usize>,
) {
for definition in definitions {
let count = available_counts.entry(definition.as_str()).or_insert(0);
*count += 1;
}
}
fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<&str, usize>) {
for definition in definitions {
if let Some(count) = available_counts.get_mut(definition.as_str()) {
if *count == 1 {
available_counts.remove(definition.as_str());
} else {
*count -= 1;
}
}
}
}
#[derive(Default)]
struct UnresolvedTokens {
chains: Vec<CssFallbackChain>,
sites: HashMap<String, TokenSite>,
}
fn record_unresolved_requirements(
source: &[CssFallbackChain],
available_counts: &HashMap<&str, usize>,
owner: &str,
css_source: Option<&str>,
out: &mut UnresolvedTokens,
) {
for requirement in source {
let tokens: Vec<String> = requirement
.tokens
.iter()
.filter(|token| !available_counts.contains_key(token.as_str()))
.cloned()
.collect();
if tokens.is_empty() {
continue;
}
for token in &tokens {
if !out.sites.contains_key(token) {
out.sites
.insert(token.clone(), token_site(owner, css_source, token));
}
}
out.chains.push(CssFallbackChain {
tokens,
has_literal_fallback: requirement.has_literal_fallback,
});
}
}
fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
None => (None, None),
};
TokenSite {
owner: owner.to_string(),
position,
snippet,
}
}
fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
let bytes = css.as_bytes();
let name = token.as_bytes();
let mut index = 0;
while index + 2 + name.len() <= bytes.len() {
let is_prefixed = bytes[index] == b'-'
&& bytes[index + 1] == b'-'
&& bytes.get(index + 2..index + 2 + name.len()) == Some(name);
if is_prefixed {
let after = index + 2 + name.len();
let is_exact_name = bytes
.get(after)
.is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
if is_exact_name {
let mut cursor = after;
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if bytes.get(cursor) != Some(&b':') {
let (line, column) = diagnostic::line_column(css, index);
return Some((line, column, source_line_snippet(css, index)));
}
}
}
index += 1;
}
None
}
fn source_line_snippet(source: &str, offset: usize) -> String {
const MAX_SNIPPET: usize = 80;
let start = source[..offset].rfind('\n').map_or(0, |n| n + 1);
let end = source[offset..]
.find('\n')
.map_or(source.len(), |n| offset + n);
let line = source[start..end].trim();
if line.chars().count() > MAX_SNIPPET {
let truncated: String = line.chars().take(MAX_SNIPPET).collect();
format!("{truncated}…")
} else {
line.to_string()
}
}
fn css_owner_label(tag_name: &str) -> String {
let mut label = String::with_capacity(tag_name.len() + 4);
label.push_str(tag_name);
label.push_str(".css");
label
}
impl CssTokenAnalysis {
pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
.map_err(|source| self.theme_token_error(source, theme))
}
#[must_use]
pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
}
#[must_use]
pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
let mut warnings = Vec::new();
for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
let help = match closest_theme_token(&token, theme.themes.values()) {
Some(suggestion) => {
format!("did you mean --{suggestion}? otherwise the literal fallback is used")
}
None => format!(
"define --{token} in the theme, or keep its literal fallback if intentional"
),
};
let diag = self.locate_diagnostic(
Diagnostic::warning(format!("unthemed CSS token --{token}"))
.code(codes::UNTHEMED_TOKEN)
.help(help),
&token,
);
warnings.push(diag);
}
warnings
}
#[cold]
#[inline(never)]
fn theme_token_error(
&self,
source: webui_tokens::TokenError,
theme: &webui_tokens::TokenFile,
) -> ParserError {
match source {
webui_tokens::TokenError::MissingToken {
theme: theme_name,
token,
} => {
let help =
match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
Some(suggestion) => {
format!("did you mean --{suggestion}? otherwise define it locally")
}
None => {
"define it locally if it should not come from the theme".to_string()
}
};
self.locate_diagnostic(
Diagnostic::error("missing theme token")
.code(codes::MISSING_THEME_TOKEN)
.help(help),
&token,
)
.into()
}
other => ParserError::Generic(format!("Theme token validation failed: {other}")),
}
}
fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
let Some(site) = self.token_sites.get(token) else {
return diag.snippet(format!("--{token}"));
};
let mut diag = diag.component(site.owner.clone());
if let Some((line, column)) = site.position {
diag = diag.position(line, column);
}
match &site.snippet {
Some(snippet) => diag.snippet(snippet.clone()),
None => diag.snippet(format!("--{token}")),
}
}
}
#[cold]
#[inline(never)]
fn closest_theme_token<'a>(
target: &str,
themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
) -> Option<String> {
let mut names: Vec<&str> = themes
.flat_map(|theme| theme.keys().map(String::as_str))
.collect();
names.sort_unstable();
names.dedup();
crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
}
impl HtmlParser {
#[must_use]
pub fn new() -> Self {
Self::with_options(ParserOptions::default())
}
#[must_use]
pub fn with_options(options: impl Into<ParserOptions>) -> Self {
let options = options.into();
Self {
component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
css_parser: CssParser::new(),
id_counter: FragmentIdCounter::new(),
condition_parser: ConditionParser::new(),
handlebars_parser: HandlebarsParser::new(),
raw_buffer: String::new(),
fragment_records: WebUIFragmentRecords::new(),
warnings: Vec::new(),
options,
plugin: None,
component_processing: ComponentProcessing::default(),
component_dom_analyses: HashMap::new(),
token_roots: Vec::new(),
fragment_css_tokens: HashMap::new(),
in_progress_fragments: HashSet::new(),
current_fragment_id: String::new(),
loop_owner_indices: HashMap::new(),
named_for_loops: NamedForLoops::default(),
named_for_fragment_ids: HashSet::new(),
next_boundary_declaration_id: 0,
boundary_names_by_owner: HashMap::new(),
in_boundary: false,
boundary_ancestor_depth: 0,
body_depth: 0,
for_depth: 0,
route_depth: 0,
foster_context_depth: 0,
boundary_parent_scope: None,
module_entry_srcs: Vec::new(),
}
}
#[must_use]
pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
Self::with_plugin_options(plugin, ParserOptions::default())
}
#[must_use]
pub fn with_plugin_options(
mut plugin: Box<dyn ParserPlugin>,
options: impl Into<ParserOptions>,
) -> Self {
let mut p = Self::with_options(options);
plugin.configure_parser(&p.options);
let component_processing = plugin.component_processing();
p.component_registry
.set_component_source_transform(component_processing.source_transform);
p.component_processing = component_processing;
p.plugin = Some(plugin);
p
}
pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
&mut self.component_registry
}
#[must_use]
pub fn component_registry(&self) -> &ComponentRegistry {
&self.component_registry
}
pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
std::mem::take(&mut self.fragment_records)
}
#[must_use]
pub fn take_warnings(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.warnings)
}
pub fn has_fragment(&self, fragment_id: &str) -> bool {
self.fragment_records.contains_key(fragment_id)
}
#[cold]
#[inline(never)]
fn finalize_boundary_metadata(&mut self) -> Result<()> {
if self.next_boundary_declaration_id != 0 {
let (outgoing, direct_boundary_records) = self.boundary_graph();
self.mark_boundary_records(&outgoing, direct_boundary_records);
if let Some(violation) = self.find_repeat_reachable_boundary() {
return Err(Self::repeat_reachable_boundary_error(&violation));
}
let occurrence_counts = self.boundary_occurrence_counts(&outgoing);
let mut unkeyed_repeat: Option<(u32, String, String)> = None;
for (fragment_id, list) in &mut self.fragment_records {
let repeated = occurrence_counts
.get(fragment_id)
.is_some_and(|count| *count > 1);
if !repeated {
continue;
}
for fragment in &mut list.fragments {
if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_mut() {
if boundary.phase() == BoundaryPhase::Start {
boundary.may_repeat = true;
if boundary.key.is_none()
&& unkeyed_repeat
.as_ref()
.is_none_or(|(id, _, _)| boundary.declaration_id < *id)
{
unkeyed_repeat = Some((
boundary.declaration_id,
boundary.owner_fragment_id.clone(),
boundary.name.clone(),
));
}
}
}
}
}
if let Some((_, owner, name)) = unkeyed_repeat {
return Err(Self::missing_boundary_key_error(&owner, &name));
}
if let Some((owner, name)) = self.find_transitively_nested_boundary() {
return Err(Self::transitively_nested_boundary_error(&owner, &name));
}
}
self.finalize_component_span_signals();
Ok(())
}
fn finalize_component_span_signals(&mut self) {
const START: &str = "streaming_span_start:";
const END: &str = "streaming_span_end:";
let spanning: HashSet<String> = self
.fragment_records
.iter()
.filter_map(|(id, list)| list.contains_boundary.then_some(id.clone()))
.collect();
for list in self.fragment_records.values_mut() {
let mut normalized = Vec::with_capacity(list.fragments.len());
for fragment in list.fragments.drain(..) {
let remove = match fragment.fragment.as_ref() {
Some(Fragment::Signal(signal)) => signal
.value
.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
.and_then(|value| {
value
.strip_prefix(START)
.or_else(|| value.strip_prefix(END))
})
.is_some_and(|tag| !spanning.contains(tag)),
_ => false,
};
if remove {
continue;
}
if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
if let Some(WebUIFragment {
fragment: Some(Fragment::Raw(previous)),
}) = normalized.last_mut()
{
previous.value.push_str(&raw.value);
continue;
}
}
normalized.push(fragment);
}
list.fragments = normalized;
}
}
fn boundary_graph(&self) -> (HashMap<String, Vec<BoundaryGraphEdge>>, Vec<String>) {
let mut outgoing: HashMap<String, Vec<BoundaryGraphEdge>> =
HashMap::with_capacity(self.fragment_records.len());
let mut direct_boundary_records = Vec::new();
for (fragment_id, list) in &self.fragment_records {
let edges = outgoing.entry(fragment_id.clone()).or_default();
for fragment in &list.fragments {
match fragment.fragment.as_ref() {
Some(Fragment::Component(component)) => {
Self::push_boundary_edge(edges, &component.fragment_id);
}
Some(Fragment::ForLoop(for_loop)) => {
Self::push_boundary_edge(edges, &for_loop.fragment_id);
}
Some(Fragment::IfCond(if_cond)) => {
Self::push_boundary_edge(edges, &if_cond.fragment_id);
}
Some(Fragment::Boundary(boundary)) => {
if boundary.phase() == BoundaryPhase::Start {
direct_boundary_records.push(fragment_id.clone());
}
}
Some(Fragment::Route(route)) => {
Self::push_route_boundary_edges(edges, route);
}
_ => {}
}
}
}
(outgoing, direct_boundary_records)
}
fn push_boundary_edge(edges: &mut Vec<BoundaryGraphEdge>, target: &str) {
if !target.is_empty() {
edges.push(BoundaryGraphEdge {
target: target.to_string(),
});
}
}
fn push_route_boundary_edges(edges: &mut Vec<BoundaryGraphEdge>, root: &WebUiFragmentRoute) {
let mut pending = vec![root];
while let Some(route) = pending.pop() {
Self::push_boundary_edge(edges, &route.fragment_id);
Self::push_boundary_edge(edges, &route.content_fragment_id);
Self::push_boundary_edge(edges, &route.pending_component);
Self::push_boundary_edge(edges, &route.error_component);
pending.extend(route.children.iter());
}
}
fn mark_boundary_records(
&mut self,
outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
direct_boundary_records: Vec<String>,
) {
for list in self.fragment_records.values_mut() {
list.contains_boundary = false;
}
let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
for (parent, edges) in outgoing {
for edge in edges {
reverse
.entry(edge.target.as_str())
.or_default()
.push(parent.as_str());
}
}
let mut marked = HashSet::with_capacity(self.fragment_records.len());
let mut pending = direct_boundary_records;
while let Some(fragment_id) = pending.pop() {
if !marked.insert(fragment_id.clone()) {
continue;
}
if let Some(list) = self.fragment_records.get_mut(&fragment_id) {
list.contains_boundary = true;
}
if let Some(parents) = reverse.get(fragment_id.as_str()) {
pending.extend(parents.iter().map(|parent| (*parent).to_string()));
}
}
}
fn boundary_occurrence_counts(
&self,
outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
) -> HashMap<String, u8> {
let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new();
for (parent, edges) in outgoing {
for edge in edges {
incoming
.entry(edge.target.as_str())
.or_default()
.push(parent.as_str());
}
}
let mut aggregate = HashMap::with_capacity(self.fragment_records.len());
for root in &self.token_roots {
if !self.fragment_records.contains_key(root) {
continue;
}
let counts = self.boundary_occurrences_from_root(root.as_str(), outgoing, &incoming);
for (fragment_id, count) in counts {
let current = aggregate.entry(fragment_id).or_insert(0);
*current = (*current).max(count);
}
}
aggregate
}
fn boundary_occurrences_from_root(
&self,
root: &str,
outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
incoming: &HashMap<&str, Vec<&str>>,
) -> HashMap<String, u8> {
let mut counts: HashMap<String, u8> = HashMap::with_capacity(self.fragment_records.len());
counts.insert(root.to_string(), 1);
let mut pending = vec![root.to_string()];
while let Some(parent) = pending.pop() {
let Some(edges) = outgoing.get(&parent) else {
continue;
};
for edge in edges {
let next = Self::recompute_boundary_occurrences(
edge.target.as_str(),
root,
incoming,
&counts,
);
let current = counts.get(&edge.target).copied().unwrap_or(0);
if next > current {
counts.insert(edge.target.clone(), next);
pending.push(edge.target.clone());
}
}
}
counts
}
fn recompute_boundary_occurrences(
fragment_id: &str,
root: &str,
incoming: &HashMap<&str, Vec<&str>>,
counts: &HashMap<String, u8>,
) -> u8 {
let mut count = u8::from(fragment_id == root);
let Some(parents) = incoming.get(fragment_id) else {
return count;
};
for parent in parents {
let parent_count = counts.get(*parent).copied().unwrap_or(0);
count = count.saturating_add(parent_count).min(2);
if count == 2 {
break;
}
}
count
}
fn find_repeat_reachable_boundary(&self) -> Option<RepeatBoundaryViolation> {
let mut origin: HashMap<&str, &str> = HashMap::new();
let mut pending: Vec<&str> = Vec::new();
let mut repeats: HashMap<&str, RepeatSite<'_>> = HashMap::new();
let mut seeds: Vec<(&str, RepeatSite<'_>)> = Vec::new();
for (owner, list) in &self.fragment_records {
for fragment in &list.fragments {
let Some(Fragment::ForLoop(for_loop)) = fragment.fragment.as_ref() else {
continue;
};
if for_loop.fragment_id.is_empty() {
continue;
}
let body = for_loop.fragment_id.as_str();
seeds.push((
body,
RepeatSite {
owner: owner.as_str(),
item: for_loop.item.as_str(),
collection: for_loop.collection.as_str(),
},
));
}
}
seeds.sort_unstable_by(|(left_body, left), (right_body, right)| {
(left.owner, left.item, left.collection, *left_body).cmp(&(
right.owner,
right.item,
right.collection,
*right_body,
))
});
for (body, site) in seeds {
if !origin.contains_key(body) {
repeats.insert(body, site);
origin.insert(body, body);
pending.push(body);
}
}
pending.reverse();
if pending.is_empty() {
return None;
}
let mounts = self.route_mount_targets();
let mut found: Option<(u32, &str, &WebUiFragmentBoundary)> = None;
let mut targets: Vec<&str> = Vec::new();
while let Some(id) = pending.pop() {
let Some(seed) = origin.get(id).copied() else {
continue;
};
let Some(list) = self.fragment_records.get(id) else {
continue;
};
for fragment in &list.fragments {
if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
if boundary.phase() == BoundaryPhase::Start
&& found
.as_ref()
.is_none_or(|(id, _, _)| boundary.declaration_id < *id)
{
found = Some((boundary.declaration_id, seed, boundary));
}
}
}
targets.clear();
push_child_records(list, &mut targets);
if let Some(mounted) = mounts.get(id) {
targets.extend(mounted.iter().copied());
}
for target in targets.drain(..) {
if target.is_empty() {
continue;
}
if !origin.contains_key(target) {
origin.insert(target, seed);
pending.push(target);
}
}
}
found.map(|(_, seed, boundary)| {
let site = repeats.get(seed);
RepeatBoundaryViolation {
repeat_owner: site.map_or_else(String::new, |site| site.owner.to_string()),
each: site.map_or_else(String::new, |site| {
format!("{} in {}", site.item, site.collection)
}),
boundary_owner: boundary.owner_fragment_id.clone(),
boundary_name: boundary.name.clone(),
}
})
}
fn route_mount_targets(&self) -> HashMap<&str, Vec<&str>> {
let outlet_hosts = self.outlet_host_records();
if outlet_hosts.is_empty() {
return HashMap::new();
}
let mut mounts: HashMap<&str, Vec<&str>> = HashMap::new();
let mut children: Vec<&str> = Vec::new();
let mut closure: HashSet<&str> = HashSet::new();
let mut pending: Vec<&str> = Vec::new();
let mut targets: Vec<&str> = Vec::new();
for list in self.fragment_records.values() {
for fragment in &list.fragments {
let Some(Fragment::Route(root)) = fragment.fragment.as_ref() else {
continue;
};
let mut routes = vec![root];
while let Some(route) = routes.pop() {
routes.extend(route.children.iter());
if route.children.is_empty() {
continue;
}
children.clear();
for child in &route.children {
push_route_records(&mut children, child);
}
closure.clear();
pending.clear();
for host in [
route.fragment_id.as_str(),
route.content_fragment_id.as_str(),
] {
if !host.is_empty() && closure.insert(host) {
pending.push(host);
}
}
while let Some(id) = pending.pop() {
if outlet_hosts.contains(id) {
mounts
.entry(id)
.or_default()
.extend(children.iter().copied());
}
let Some(list) = self.fragment_records.get(id) else {
continue;
};
targets.clear();
push_child_records(list, &mut targets);
for target in targets.drain(..) {
if !target.is_empty() && closure.insert(target) {
pending.push(target);
}
}
}
}
}
}
mounts
}
fn outlet_host_records(&self) -> HashSet<&str> {
self.fragment_records
.iter()
.filter(|(_, list)| {
list.fragments
.iter()
.any(|fragment| matches!(fragment.fragment.as_ref(), Some(Fragment::Outlet(_))))
})
.map(|(id, _)| id.as_str())
.collect()
}
fn find_transitively_nested_boundary(&self) -> Option<(String, String)> {
let mut nested: Option<(u32, &str, &str)> = None;
for list in self.fragment_records.values() {
let mut active: Option<&WebUiFragmentBoundary> = None;
for fragment in &list.fragments {
let Some(boundary) = active else {
if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
if boundary.phase() == BoundaryPhase::Start {
active = Some(boundary);
}
}
continue;
};
if let Some(Fragment::Boundary(marker)) = fragment.fragment.as_ref() {
if marker.phase() == BoundaryPhase::End {
active = None;
}
continue;
}
if !self.fragment_reaches_boundary(fragment) {
continue;
}
if nested
.as_ref()
.is_none_or(|(id, _, _)| boundary.declaration_id < *id)
{
nested = Some((
boundary.declaration_id,
boundary.owner_fragment_id.as_str(),
boundary.name.as_str(),
));
}
}
}
nested.map(|(_, owner, name)| (owner.to_string(), name.to_string()))
}
fn fragment_reaches_boundary(&self, fragment: &WebUIFragment) -> bool {
let reaches = |id: &str| {
!id.is_empty()
&& self
.fragment_records
.get(id)
.is_some_and(|list| list.contains_boundary)
};
match fragment.fragment.as_ref() {
Some(Fragment::Component(component)) => reaches(&component.fragment_id),
Some(Fragment::ForLoop(for_loop)) => reaches(&for_loop.fragment_id),
Some(Fragment::IfCond(if_cond)) => reaches(&if_cond.fragment_id),
Some(Fragment::Route(route)) => {
let mut pending = vec![route];
while let Some(current) = pending.pop() {
if reaches(¤t.fragment_id)
|| reaches(¤t.content_fragment_id)
|| reaches(¤t.pending_component)
|| reaches(¤t.error_component)
{
return true;
}
pending.extend(current.children.iter());
}
false
}
_ => false,
}
}
#[cold]
#[inline(never)]
fn transitively_nested_boundary_error(owner: &str, name: &str) -> ParserError {
Diagnostic::error(format!(
"boundary \"{name}\" transitively contains another boundary"
))
.code(codes::NESTED_BOUNDARY)
.component(owner)
.element("boundary")
.snippet(format!("name=\"{name}\""))
.help("remove the inner declaration or move it outside the enclosing boundary, including declarations reached through components and runtime branches")
.into()
}
#[cold]
#[inline(never)]
fn repeat_reachable_boundary_error(violation: &RepeatBoundaryViolation) -> ParserError {
let RepeatBoundaryViolation {
repeat_owner,
each,
boundary_owner,
boundary_name,
} = violation;
Diagnostic::error(format!(
"boundary \"{boundary_name}\" in {boundary_owner} is reachable from a <for> repeat body"
))
.code(codes::BOUNDARY_IN_REPEAT)
.component(repeat_owner)
.element("for")
.snippet(format!("each=\"{each}\""))
.help(
"a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
whole <for> in one <boundary> to render the list as a single independently paced \
region — this includes declarations reached through components, runtime branches, \
routes, and outlets",
)
.into()
}
#[must_use]
pub fn module_entry_srcs(&self) -> &[String] {
&self.module_entry_srcs
}
pub fn component_shadow_dom_usage(&self) -> impl Iterator<Item = (&str, bool)> {
self.component_dom_analyses
.iter()
.map(|(tag_name, analysis)| (tag_name.as_str(), analysis.uses_shadow_dom))
}
pub fn component_work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
self.component_registry.work_policies()
}
pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
self.plugin
.take()
.map_or(Ok(ParserPluginArtifacts::None), |plugin| plugin.finish())
}
#[must_use]
pub fn take_tokens(&mut self) -> Vec<String> {
self.token_analysis().protocol_tokens
}
#[must_use]
pub fn token_analysis(&self) -> CssTokenAnalysis {
let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
let mut protocol_token_set = HashSet::new();
for chain in &fallback_chains {
for token in &chain.tokens {
protocol_token_set.insert(token.as_str());
}
}
let mut protocol_tokens: Vec<String> =
protocol_token_set.into_iter().map(str::to_owned).collect();
protocol_tokens.sort();
CssTokenAnalysis {
protocol_tokens,
fallback_chains,
token_sites,
}
}
fn collect_unresolved_fallback_chains(
&self,
) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
let mut out = UnresolvedTokens::default();
let mut available_counts: HashMap<&str, usize> = HashMap::new();
let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
let mut active_fragments = HashSet::new();
let mut visits =
(!self.named_for_fragment_ids.is_empty()).then(scoped_visits::ScopedVisits::default);
for root in self.token_roots.iter().rev() {
ops.push(TokenGraphOp::EnterFragment(root.as_str()));
}
while let Some(op) = ops.pop() {
match op {
TokenGraphOp::EnterFragment(fragment_id) => {
if let Some(visits) = &mut visits {
if active_fragments.contains(fragment_id)
|| !visits.insert(fragment_id, available_counts.keys().copied())
{
continue;
}
active_fragments.insert(fragment_id);
ops.push(TokenGraphOp::ExitFragment(fragment_id));
}
self.enter_token_fragment(
fragment_id,
&mut available_counts,
&mut out,
&mut ops,
);
}
TokenGraphOp::ExitFragment(fragment_id) => {
active_fragments.remove(fragment_id);
}
TokenGraphOp::EnterComponent(tag_name) => {
self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
}
TokenGraphOp::EnterRoute(route) => {
self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
}
TokenGraphOp::ExitDefinitions(definitions) => {
remove_token_definitions(definitions, &mut available_counts);
}
}
}
(out.chains, out.sites)
}
fn enter_token_fragment<'a>(
&'a self,
fragment_id: &'a str,
available_counts: &mut HashMap<&'a str, usize>,
out: &mut UnresolvedTokens,
ops: &mut Vec<TokenGraphOp<'a>>,
) {
if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
add_token_definitions(&css.definitions, available_counts);
record_unresolved_requirements(
&css.fallback_chains,
available_counts,
fragment_id,
None,
out,
);
ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
}
let Some(fragments) = self.fragment_records.get(fragment_id) else {
return;
};
for fragment in fragments.fragments.iter().rev() {
match fragment.fragment.as_ref() {
Some(web_ui_fragment::Fragment::Component(component)) => {
ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
}
Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
}
Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
}
Some(web_ui_fragment::Fragment::Route(route)) => {
ops.push(TokenGraphOp::EnterRoute(route));
}
_ => {}
}
}
}
fn enter_token_component<'a>(
&'a self,
tag_name: &'a str,
available_counts: &mut HashMap<&'a str, usize>,
out: &mut UnresolvedTokens,
ops: &mut Vec<TokenGraphOp<'a>>,
) {
let Some(component) = self.component_registry.get(tag_name) else {
return;
};
add_token_definitions(&component.css_definitions, available_counts);
record_unresolved_requirements(
&component.css_fallback_chains,
available_counts,
&css_owner_label(tag_name),
self.component_registry.diagnostic_css_content(tag_name),
out,
);
ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
ops.push(TokenGraphOp::EnterFragment(tag_name));
}
fn enter_token_route<'a>(
&'a self,
route: &'a WebUiFragmentRoute,
available_counts: &mut HashMap<&'a str, usize>,
out: &mut UnresolvedTokens,
ops: &mut Vec<TokenGraphOp<'a>>,
) {
if !route.content_fragment_id.is_empty() {
ops.push(TokenGraphOp::EnterFragment(
route.content_fragment_id.as_str(),
));
}
let Some(component) = self.component_registry.get(&route.fragment_id) else {
return;
};
add_token_definitions(&component.css_definitions, available_counts);
record_unresolved_requirements(
&component.css_fallback_chains,
available_counts,
&css_owner_label(&route.fragment_id),
self.component_registry
.diagnostic_css_content(&route.fragment_id),
out,
);
ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
if !route.error_component.is_empty() {
ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
}
if !route.pending_component.is_empty() {
ops.push(TokenGraphOp::EnterComponent(
route.pending_component.as_str(),
));
}
for child in route.children.iter().rev() {
ops.push(TokenGraphOp::EnterRoute(child));
}
ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
}
fn record_fragment_css_tokens(
&mut self,
definitions: HashSet<String>,
fallback_chains: Vec<CssFallbackChain>,
) {
let css = self
.fragment_css_tokens
.entry(self.current_fragment_id.clone())
.or_default();
css.definitions.extend(definitions);
css.definitions.sort();
css.definitions.dedup();
css.fallback_chains.extend(fallback_chains);
}
pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
let fragment_key = fragment_id.to_string();
let is_token_root = self.in_progress_fragments.is_empty();
if is_token_root {
self.module_entry_srcs.clear();
}
self.boundary_names_by_owner.remove(&fragment_key);
let previous_fragment_id =
std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
if !self.in_progress_fragments.insert(fragment_key.clone()) {
let err = self
.authoring_error(
codes::RECURSIVE_TEMPLATE,
format!("recursive template reference while parsing <{fragment_id}>"),
)
.help(
"move the recursive usage behind runtime data, or split the component graph \
so templates do not reference themselves at build time",
)
.into();
self.current_fragment_id = previous_fragment_id;
return Err(err);
}
if is_token_root && !self.token_roots.contains(&fragment_key) {
self.token_roots.push(fragment_key.clone());
}
let previous_in_boundary = std::mem::replace(&mut self.in_boundary, false);
let previous_boundary_ancestor_depth = self.boundary_ancestor_depth;
if previous_in_boundary {
self.boundary_ancestor_depth += 1;
}
let previous_body_depth = std::mem::replace(&mut self.body_depth, 0);
let previous_for_depth = std::mem::replace(&mut self.for_depth, 0);
let previous_route_depth = std::mem::replace(&mut self.route_depth, 0);
let previous_foster_depth = std::mem::replace(&mut self.foster_context_depth, 0);
let previous_parent_scope = self.boundary_parent_scope.take();
let previous_named_for_loops = std::mem::take(&mut self.named_for_loops);
let owner_index = self.loop_owner_indices.len() + 1;
self.loop_owner_indices
.entry(fragment_key.clone())
.or_insert(owner_index);
let mut result = self.parse_inner(fragment_id, html_content);
if is_token_root && result.is_ok() {
result = self.finalize_boundary_metadata();
if result.is_ok() {
self.finalize_outlet_warnings();
}
}
self.in_boundary = previous_in_boundary;
self.boundary_ancestor_depth = previous_boundary_ancestor_depth;
self.body_depth = previous_body_depth;
self.for_depth = previous_for_depth;
self.route_depth = previous_route_depth;
self.foster_context_depth = previous_foster_depth;
self.boundary_parent_scope = previous_parent_scope;
self.named_for_loops = previous_named_for_loops;
self.in_progress_fragments.remove(&fragment_key);
self.current_fragment_id = previous_fragment_id;
result
}
fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
if html_content.len() > MAX_TEMPLATE_BYTES {
return Err(ParserError::Html(format!(
"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.",
html_content.len()
)));
}
self.raw_buffer.clear();
if let Some(ref mut plugin) = self.plugin {
plugin.begin_fragment(FragmentContext { id: fragment_id });
}
let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
self.named_for_loops.validate(fragment_id, html_content)?;
self.flush_raw_buffer(&mut entry_fragment);
self.fragment_records.insert(
fragment_id.to_string(),
FragmentList {
fragments: entry_fragment,
contains_boundary: false,
},
);
Ok(())
}
fn add_raw_fragment(&mut self, content: &str) {
if !content.is_empty() {
self.raw_buffer.push_str(content);
}
}
fn add_for_fragment(
&mut self,
item: String,
collection: String,
fragment_id: String,
fragments: &mut Vec<WebUIFragment>,
) {
self.flush_raw_buffer(fragments);
fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
}
fn add_if_fragment(
&mut self,
condition: ConditionExpr,
fragment_id: String,
fragments: &mut Vec<WebUIFragment>,
) {
self.flush_raw_buffer(fragments);
fragments.push(WebUIFragment::if_cond(condition, fragment_id));
}
fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
self.flush_raw_buffer(fragments);
fragments.push(fragment);
}
fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
if !self.raw_buffer.is_empty() {
fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
}
}
fn should_emit_text_content(content: &str) -> bool {
if content.is_empty() {
return false;
}
if !content.trim().is_empty() {
return true;
}
content.chars().all(char::is_whitespace)
&& !content.contains('\n')
&& !content.contains('\r')
}
fn parse_range(
&mut self,
source: &str,
range: Range<usize>,
fragments: &mut Vec<WebUIFragment>,
depth: usize,
) -> Result<()> {
let mut ops = vec![ParseOp::Parse { range, depth }];
let mut outlet_seen = false;
while let Some(op) = ops.pop() {
match op {
ParseOp::Parse { range, depth } => {
if depth > MAX_TEMPLATE_DEPTH {
return Err(self
.html_error(
codes::EXCESSIVE_NESTING,
format!(
"template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
),
source,
range.start,
)
.help(
"split deeply nested markup into components, or reduce generated nesting",
)
.into());
}
let end = range.end;
let mut index = range.start;
while index < end {
let remaining = &source[index..end];
if remaining.starts_with("<!--") {
let Some(close) = html::find_comment_close(remaining) else {
return Err(self
.html_error(
codes::UNTERMINATED_HTML_COMMENT,
"unterminated HTML comment",
source,
index,
)
.help("close the comment with `-->`")
.into());
};
index += close;
continue;
}
if remaining.starts_with("<!") {
let Some(close) = html::find_declaration_close(remaining) else {
return Err(self
.html_error(
codes::UNTERMINATED_HTML_DECLARATION,
"unterminated HTML declaration",
source,
index,
)
.help("close the declaration with `>`")
.into());
};
self.add_raw_fragment(&remaining[..close]);
index += close;
continue;
}
if remaining.starts_with('<') {
let Some(tag) = html::parse_tag(remaining) else {
return Err(self
.html_error(
codes::MALFORMED_HTML_TAG,
"malformed HTML tag",
source,
index,
)
.help(
"close the tag with `>`, or escape a literal `<` as `<`",
)
.into());
};
if tag.closing {
return Err(self
.html_error(
codes::UNEXPECTED_CLOSING_TAG,
format!("unexpected closing tag </{}>", tag.name),
source,
index,
)
.help("remove it, or add the matching opening tag before it")
.into());
}
let content_start = index + tag.close + 1;
let (content_end, close_end) = if tag.self_closing
|| html::is_void_element(tag.name)
{
(content_start, content_start)
} else if let Some((close_start, close_end)) =
html::find_matching_end(remaining, tag.name, tag.close + 1)
{
(index + close_start, index + close_end)
} else {
return Err(self
.html_error(
codes::UNCLOSED_HTML_TAG,
format!("unclosed <{}> tag", tag.name),
source,
index,
)
.help(format!(
"add the matching </{}> closing tag, or make the element self-closing",
tag.name
))
.into());
};
let element = Element {
source,
start: index,
tag,
content_start,
content_end,
close_end,
};
if close_end < end {
ops.push(ParseOp::Parse {
range: close_end..end,
depth,
});
}
match element.name() {
"for" => {
self.enter_for_directive(&element, fragments, depth, &mut ops)?;
}
"if" => {
self.enter_if_directive(&element, fragments, depth, &mut ops)?;
}
"route" => {
self.process_route_directive(&element, fragments, depth)?;
}
"outlet" => {
self.flush_raw_buffer(fragments);
if outlet_seen {
self.warnings.push(self.multiple_outlets_warning(&element));
}
outlet_seen = true;
fragments.push(WebUIFragment::outlet());
}
"boundary" => {
self.enter_boundary_directive(
&element, fragments, depth, &mut ops,
)?;
}
name if name.eq_ignore_ascii_case("body") => {
self.enter_body_element(&element, fragments, depth, &mut ops)?;
}
name if name.eq_ignore_ascii_case("head") => {
self.enter_head_element(&element, fragments, depth, &mut ops)?;
}
name if name.eq_ignore_ascii_case("webui-hydrate") => {
return Err(self.authored_webui_hydrate_error(&element));
}
name if name.eq_ignore_ascii_case("style") => {
self.process_style_element(&element, fragments)?;
}
_ if self.component_registry.contains(element.name()) => {
self.enter_component_directive(
&element, fragments, depth, &mut ops,
)?;
}
_ => {
self.check_component_typo(&element)?;
self.enter_regular_element(
&element, fragments, depth, &mut ops,
)?;
}
}
break;
}
let next = remaining.find('<').unwrap_or(remaining.len());
let text_end = if next == 0 {
remaining
.chars()
.next()
.map_or(remaining.len(), char::len_utf8)
} else {
next
};
self.process_text(&remaining[..text_end], fragments)?;
index += text_end;
}
}
ParseOp::EmitClose(name) => {
self.add_raw_fragment("</");
self.add_raw_fragment(name);
self.add_raw_fragment(">");
}
ParseOp::EndComponent(name) => {
self.add_raw_fragment("</");
self.add_raw_fragment(name);
self.add_raw_fragment(">");
self.flush_raw_buffer(fragments);
fragments.push(structural_signal(format!("streaming_span_end:{name}")));
}
ParseOp::EndHead(name) => {
self.flush_raw_buffer(fragments);
fragments.push(structural_signal("head_end"));
self.add_raw_fragment("</");
self.add_raw_fragment(name);
self.add_raw_fragment(">");
}
ParseOp::EndBody(name) => {
self.flush_raw_buffer(fragments);
fragments.push(structural_signal("body_end"));
self.add_raw_fragment("</");
self.add_raw_fragment(name);
self.add_raw_fragment(">");
self.body_depth = self.body_depth.saturating_sub(1);
}
ParseOp::CompleteBoundary { boundary } => {
self.flush_raw_buffer(fragments);
fragments.push(WebUIFragment::boundary_end(boundary.declaration_id));
self.in_boundary = false;
}
ParseOp::RestoreFosterDepth(previous) => {
self.foster_context_depth = previous;
}
ParseOp::RestoreBoundaryParentScope(previous) => {
self.boundary_parent_scope = previous;
}
ParseOp::CompleteFor {
parent,
item,
collection,
fragment_id,
named_definition,
previous_for_depth,
} => {
self.for_depth = previous_for_depth;
self.flush_raw_buffer(fragments);
let for_fragment = std::mem::take(fragments);
*fragments = parent.fragments;
self.raw_buffer = parent.raw_buffer;
if !for_fragment.is_empty() || named_definition {
self.fragment_records.insert(
fragment_id.clone(),
FragmentList {
fragments: for_fragment,
contains_boundary: false,
},
);
} else {
continue;
}
self.add_for_fragment(item, collection, fragment_id, fragments);
}
ParseOp::CompleteIf {
parent,
condition,
fragment_id,
} => {
self.flush_raw_buffer(fragments);
let if_fragment = std::mem::take(fragments);
*fragments = parent.fragments;
self.raw_buffer = parent.raw_buffer;
self.fragment_records.insert(
fragment_id.clone(),
FragmentList {
fragments: if_fragment,
contains_boundary: false,
},
);
self.add_if_fragment(condition, fragment_id, fragments);
}
}
}
Ok(())
}
fn enter_regular_element<'a>(
&mut self,
element: &Element<'a>,
fragments: &mut Vec<WebUIFragment>,
depth: usize,
ops: &mut Vec<ParseOp<'a>>,
) -> Result<()> {
if !self.in_boundary
&& self.boundary_ancestor_depth == 0
&& element.name().eq_ignore_ascii_case("script")
{
self.record_module_entry(element);
}
self.add_raw_fragment("<");
self.add_raw_fragment(element.name());
let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
if let Some(ref mut p) = self.plugin {
if let Some(data) = p.finish_opening_tag(ElementStartContext {
tag_name: element.name(),
binding_count,
}) {
self.add_fragment(WebUIFragment::plugin(data), fragments);
}
}
if element.self_closing() {
self.add_raw_fragment("/>");
return Ok(());
}
self.add_raw_fragment(">");
if element.name().eq_ignore_ascii_case("template")
&& element
.attrs()
.find(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
.and_then(|attr| attr.value)
.is_some_and(|mode| mode.eq_ignore_ascii_case("open"))
&& self
.component_dom_analyses
.get(&self.current_fragment_id)
.is_some_and(|analysis| analysis.uses_shadow_dom)
{
self.flush_raw_buffer(fragments);
fragments.push(structural_signal(format!(
"shadow_styles:{}",
self.current_fragment_id
)));
}
if !element.is_void() {
if element.close_end() > element.content_end() {
ops.push(ParseOp::EmitClose(element.name()));
}
self.enter_foster_context(element.name(), ops);
self.enter_boundary_parent_scope(element, ops);
ops.push(ParseOp::Parse {
range: element.inner(),
depth: depth + 1,
});
}
Ok(())
}
fn record_module_entry(&mut self, element: &Element<'_>) {
let Some(src) = element.attr("src") else {
return;
};
if src.is_empty() || src.contains("{{") {
return;
}
if element
.attrs()
.find(|attr| attr.name.eq_ignore_ascii_case("fetchpriority"))
.and_then(|attr| attr.value)
.is_some_and(|priority| priority.eq_ignore_ascii_case("low"))
{
return;
}
if !element
.attr("type")
.is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
{
return;
}
if self.module_entry_srcs.iter().any(|seen| seen == src) {
return;
}
self.module_entry_srcs.push(src.to_string());
}
fn enter_head_element<'a>(
&mut self,
element: &Element<'a>,
fragments: &mut Vec<WebUIFragment>,
depth: usize,
ops: &mut Vec<ParseOp<'a>>,
) -> Result<()> {
if element.name() == "head" {
self.add_raw_fragment("<head>");
} else {
self.add_raw_fragment("<");
self.add_raw_fragment(element.name());
let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
if let Some(ref mut p) = self.plugin {
if let Some(data) = p.finish_opening_tag(ElementStartContext {
tag_name: element.name(),
binding_count,
}) {
self.add_fragment(WebUIFragment::plugin(data), fragments);
}
}
self.add_raw_fragment(">");
}
self.flush_raw_buffer(fragments);
fragments.push(structural_signal("head_start"));
ops.push(ParseOp::EndHead(element.name()));
ops.push(ParseOp::Parse {
range: element.inner(),
depth: depth + 1,
});
Ok(())
}
fn enter_body_element<'a>(
&mut self,
element: &Element<'a>,
fragments: &mut Vec<WebUIFragment>,
depth: usize,
ops: &mut Vec<ParseOp<'a>>,
) -> Result<()> {
self.add_raw_fragment("<");
self.add_raw_fragment(element.name());
let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
if let Some(ref mut p) = self.plugin {
if let Some(data) = p.finish_opening_tag(ElementStartContext {
tag_name: element.name(),
binding_count,
}) {
self.add_fragment(WebUIFragment::plugin(data), fragments);
}
}
self.add_raw_fragment(">");
self.flush_raw_buffer(fragments);
fragments.push(structural_signal("body_start"));
self.body_depth += 1;
ops.push(ParseOp::EndBody(element.name()));
ops.push(ParseOp::Parse {
range: element.inner(),
depth: depth + 1,
});
Ok(())
}
#[cold]
#[inline(never)]
fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
let diag = Diagnostic::error(title).code(code);
if self.current_fragment_id.is_empty() {
diag
} else {
diag.component(self.current_fragment_id.clone())
}
}
#[cold]
#[inline(never)]
fn authoring_error_at(
&self,
code: &'static str,
title: impl Into<String>,
element: &Element<'_>,
) -> Diagnostic {
self.authoring_error(code, title)
.at_offset(element.source(), element.start)
}
#[cold]
#[inline(never)]
fn html_error(
&self,
code: &'static str,
title: impl Into<String>,
source: &str,
offset: usize,
) -> Diagnostic {
self.authoring_error(code, title).at_offset(source, offset)
}
#[cold]
#[inline(never)]
fn multiple_outlets_warning(&self, element: &Element<'_>) -> Diagnostic {
outlet_warnings::multiple_outlets_warning(&self.current_fragment_id)
.at_offset(element.source(), element.start)
}
#[cold]
#[inline(never)]
fn unknown_component_help(&self, name: &str) -> String {
match suggest::closest_match(name, self.component_registry.names()) {
Some(suggestion) => format!(
"did you mean <{suggestion}>? otherwise register the component by adding a \
matching .html file"
),
None => "register the component (add a matching .html file) or check the tag name \
for a typo"
.to_string(),
}
}
fn suggest_component(&self, tag: &str) -> Option<&str> {
let (prefix, _) = tag.split_once('-')?;
let same_namespace = self
.component_registry
.names()
.filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
suggest::closest_match(tag, same_namespace)
}
fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
if let Some(suggestion) = self.suggest_component(element.name()) {
return Err(self
.authoring_error_at(
codes::UNKNOWN_COMPONENT,
format!("unknown component <{}>", element.name()),
element,
)
.help(format!(
"did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
.html file",
element.name()
))
.into());
}
Ok(())
}
#[cold]
#[inline(never)]
fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
.filter(|&name| name != expected)
}
#[cold]
#[inline(never)]
fn css_diagnostic(&self, err: ParserError) -> ParserError {
match err {
ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
other => other,
}
}
#[cold]
#[inline(never)]
fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
let diag = self
.authoring_error_at(
codes::MISSING_FOR_EACH,
"missing each attribute on <for>",
element,
)
.element("for");
match Self::attr_typo_suggestion(element, "each") {
Some(typo) => diag.help(format!(
"found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
)),
None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
}
.into()
}
fn enter_for_directive<'a>(
&mut self,
element: &Element<'a>,
fragments: &mut Vec<WebUIFragment>,
depth: usize,
ops: &mut Vec<ParseOp<'a>>,
) -> Result<()> {
let each = element
.attr("each")
.ok_or_else(|| self.for_each_missing_error(element))?;
let (item, collection) = parse_for_each(&self.current_fragment_id, each)
.map_err(|error| locate_for_error(error, element))?;
let named_id = validate_for_id(&self.current_fragment_id, &element.tag)
.map_err(|error| locate_for_error(error, element))?;
let fragment_id = if let Some(id) = named_id {
self.named_for_loops
.register(
&self.current_fragment_id,
id,
item,
(!element.self_closing(), element.start),
)
.map_err(|error| locate_for_error(error, element))?;
let scoped_id = format!(
"{id}-{}",
self.loop_owner_indices[&self.current_fragment_id]
);
if !self.named_for_fragment_ids.contains(&scoped_id)
&& (self.id_counter.has_generated(&scoped_id)
|| self.fragment_records.contains_key(&scoped_id)
|| self.component_registry.contains(&scoped_id)
|| self.in_progress_fragments.contains(&scoped_id))
{
return Err(self.for_id_collision_error(element, &scoped_id));
}
self.named_for_fragment_ids.insert(scoped_id.clone());
scoped_id
} else {
self.next_fragment_id("for")
};
if named_id.is_some() && element.self_closing() {
self.add_for_fragment(
item.to_string(),
collection.to_string(),
fragment_id,
fragments,
);
return Ok(());
}
let parent = ParseContext {
fragments: std::mem::take(fragments),
raw_buffer: std::mem::take(&mut self.raw_buffer),
};
let previous_for_depth = self.for_depth;
self.for_depth += 1;
ops.push(ParseOp::CompleteFor {
parent,
item: item.to_string(),
collection: collection.to_string(),
fragment_id,
named_definition: named_id.is_some(),
previous_for_depth,
});
ops.push(ParseOp::Parse {
range: element.inner(),
depth: depth + 1,
});
Ok(())
}
fn next_fragment_id(&mut self, prefix: &str) -> String {
loop {
let id = self.id_counter.next_id(prefix);
if !self.named_for_fragment_ids.contains(&id) {
return id;
}
}
}
#[cold]
#[inline(never)]
fn for_id_collision_error(&self, element: &Element<'_>, id: &str) -> ParserError {
self.authoring_error_at(
codes::INVALID_FOR_ID,
"named <for> id conflicts with an existing fragment",
element,
)
.element("for")
.help(format!(
"choose a different id; the fragment name '{id}' is already in use"
))
.into()
}
#[cold]
#[inline(never)]
fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
let diag = self
.authoring_error_at(
codes::MISSING_IF_CONDITION,
"missing condition attribute on <if>",
element,
)
.element("if");
match Self::attr_typo_suggestion(element, "condition") {
Some(typo) => diag.help(format!(
"found `{typo}` \u{2014} did you mean condition=\"expression\"?"
)),
None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
}
.into()
}
#[cold]
#[inline(never)]
fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
self.authoring_error_at(
codes::INVALID_IF_CONDITION,
"invalid <if> condition expression",
element,
)
.element("if")
.snippet(format!("condition=\"{condition}\""))
.help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
.into()
}
fn enter_if_directive<'a>(
&mut self,
element: &Element<'a>,
fragments: &mut Vec<WebUIFragment>,
depth: usize,
ops: &mut Vec<ParseOp<'a>>,
) -> Result<()> {
let condition_str = element
.attr("condition")
.ok_or_else(|| self.if_condition_missing_error(element))?;
let condition = self
.condition_parser
.parse(strip_condition_braces(condition_str))
.map_err(|_| self.if_condition_invalid_error(element, condition_str))?;
self.flush_raw_buffer(fragments);
let parent = ParseContext {
fragments: std::mem::take(fragments),
raw_buffer: std::mem::take(&mut self.raw_buffer),
};
let fragment_id = self.next_fragment_id("if");
ops.push(ParseOp::CompleteIf {
parent,
condition,
fragment_id,
});
ops.push(ParseOp::Parse {
range: element.inner(),
depth: depth + 1,
});
Ok(())
}
#[inline]
fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
match foster_context_of(name) {
FosterContext::Transparent => {}
FosterContext::Hostile => {
ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
self.foster_context_depth += 1;
}
FosterContext::Barrier => {
if self.foster_context_depth != 0 {
ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
self.foster_context_depth = 0;
}
}
}
}
#[inline]
fn enter_boundary_parent_scope<'a>(
&mut self,
element: &Element<'a>,
ops: &mut Vec<ParseOp<'a>>,
) {
if element.name().eq_ignore_ascii_case("template") && element.has_attr("shadowrootmode") {
return;
}
let Some(scope) = boundary_parent_scope(element.name()) else {
return;
};
ops.push(ParseOp::RestoreBoundaryParentScope(
self.boundary_parent_scope,
));
self.boundary_parent_scope = Some(scope);
}
#[inline]
fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
ops.push(ParseOp::RestoreBoundaryParentScope(
self.boundary_parent_scope,
));
self.boundary_parent_scope = Some("component host content");
}
fn enter_boundary_directive<'a>(
&mut self,
element: &Element<'a>,
fragments: &mut Vec<WebUIFragment>,
depth: usize,
ops: &mut Vec<ParseOp<'a>>,
) -> Result<()> {
if let Some(scope) = self.boundary_parent_scope {
return Err(self.boundary_scope_error(element, scope));
}
if self.in_boundary {
return Err(self.nested_boundary_error(element));
}
if self.for_depth != 0 {
return Err(self.boundary_in_repeat_error(element));
}
let owner_fragment_id = self.current_fragment_id.clone();
let owner_is_component = self.component_registry.contains(&owner_fragment_id);
if !owner_is_component && self.body_depth == 0 && self.route_depth == 0 {
return Err(self.boundary_outside_body_error(element));
}
if self.foster_context_depth != 0 {
return Err(self.boundary_in_foster_context_error(element));
}
let name = self.validate_boundary_name(element)?;
let inserted = self
.boundary_names_by_owner
.entry(owner_fragment_id.clone())
.or_default()
.insert(name.clone());
if !inserted {
return Err(self.duplicate_boundary_name_error(element, &name));
}
let key = self.validate_boundary_key(element)?;
let declaration_id = self.allocate_boundary_declaration_id(element)?;
self.in_boundary = true;
self.add_fragment(
WebUIFragment {
fragment: Some(Fragment::Boundary(WebUiFragmentBoundary {
declaration_id,
owner_fragment_id,
name,
key,
may_repeat: false,
phase: BoundaryPhase::Start as i32,
})),
},
fragments,
);
ops.push(ParseOp::CompleteBoundary {
boundary: PendingBoundary { declaration_id },
});
ops.push(ParseOp::Parse {
range: element.inner(),
depth: depth + 1,
});
Ok(())
}
fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
let name = element.attr("name").unwrap_or_default();
if name.trim().is_empty() {
return Err(self.missing_boundary_name_error(element));
}
if name.contains("{{") {
return Err(self.invalid_boundary_name_error(element, name));
}
Ok(name.to_string())
}
fn validate_boundary_key(&self, element: &Element<'_>) -> Result<Option<String>> {
if !element.has_attr("key") {
return Ok(None);
}
let Some(raw) = element.attr("key") else {
return Err(self.invalid_boundary_key_error(element, ""));
};
let trimmed = raw.trim();
let braced = trimmed.starts_with("{{") || trimmed.ends_with("}}");
let valid = if braced {
trimmed
.strip_prefix("{{")
.and_then(|value| value.strip_suffix("}}"))
.filter(|value| !value.starts_with('{') && !value.ends_with('}'))
.is_some_and(|value| !value.trim().is_empty())
} else {
!trimmed.is_empty()
};
if !valid {
return Err(self.invalid_boundary_key_error(element, raw));
}
Ok(Some(raw.to_string()))
}
fn allocate_boundary_declaration_id(&mut self, element: &Element<'_>) -> Result<u32> {
let declaration_id = self.next_boundary_declaration_id;
let Some(next) = declaration_id.checked_add(1) else {
return Err(self.too_many_boundaries_error(element));
};
self.next_boundary_declaration_id = next;
Ok(declaration_id)
}
#[cold]
#[inline(never)]
fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
self.authoring_error_at(
codes::MISSING_BOUNDARY_NAME,
"missing name attribute on <boundary>",
element,
)
.element("boundary")
.help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
.into()
}
#[cold]
#[inline(never)]
fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
self.authoring_error_at(
codes::INVALID_BOUNDARY_NAME,
"invalid name attribute on <boundary>",
element,
)
.element("boundary")
.snippet(format!("name=\"{name}\""))
.help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
.into()
}
#[cold]
#[inline(never)]
fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
self.authoring_error_at(
codes::DUPLICATE_BOUNDARY_NAME,
format!("duplicate <boundary> name \"{name}\""),
element,
)
.element("boundary")
.help("boundary names must be unique within their entry or component template; rename one of the declarations")
.into()
}
#[cold]
#[inline(never)]
fn missing_boundary_key_error(owner: &str, name: &str) -> ParserError {
Diagnostic::error(format!(
"boundary \"{name}\" renders more than once and has no key"
))
.code(codes::MISSING_BOUNDARY_KEY)
.component(owner)
.element("boundary")
.snippet(format!("name=\"{name}\""))
.help(
"the owning template is rendered from more than one callsite, so every occurrence \
needs a distinct key, e.g. key=\"{{sectionId}}\" — or render the declaration from a \
single callsite",
)
.into()
}
#[cold]
#[inline(never)]
fn boundary_in_repeat_error(&self, element: &Element<'_>) -> ParserError {
let name = element.attr("name").unwrap_or_default();
self.authoring_error_at(
codes::BOUNDARY_IN_REPEAT,
"<boundary> is not valid inside a <for> repeat",
element,
)
.element("boundary")
.snippet(format!("name=\"{name}\""))
.help(
"a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
whole <for> in one <boundary> to render the list as a single independently paced \
region",
)
.into()
}
#[cold]
#[inline(never)]
fn invalid_boundary_key_error(&self, element: &Element<'_>, key: &str) -> ParserError {
self.authoring_error_at(
codes::INVALID_BOUNDARY_KEY,
"invalid key expression on <boundary>",
element,
)
.element("boundary")
.snippet(format!("key=\"{key}\""))
.help("use a non-empty expression such as key=\"item.id\" or key=\"{{item.id}}\"")
.into()
}
#[cold]
#[inline(never)]
fn too_many_boundaries_error(&self, element: &Element<'_>) -> ParserError {
self.authoring_error_at(
codes::TOO_MANY_BOUNDARIES,
"too many boundary declarations",
element,
)
.element("boundary")
.help("split this build into smaller protocols so each declaration has a unique u32 ID")
.into()
}
#[cold]
#[inline(never)]
fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
self.authoring_error_at(
codes::NESTED_BOUNDARY,
"nested <boundary> is not allowed",
element,
)
.element("boundary")
.help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
.into()
}
#[cold]
#[inline(never)]
fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
self.authoring_error_at(
codes::BOUNDARY_CROSSES_SCOPE,
format!("<boundary> cannot appear inside {scope}"),
element,
)
.element("boundary")
.help("move the <boundary> outside this raw, inert, or component-host content scope")
.into()
}
#[cold]
#[inline(never)]
fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
self.authoring_error_at(
codes::BOUNDARY_OUTSIDE_BODY,
"<boundary> must appear inside <body>",
element,
)
.element("boundary")
.help("move the <boundary> between the opening <body> and its matching </body>")
.into()
}
#[cold]
#[inline(never)]
fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
self.authoring_error_at(
codes::BOUNDARY_IN_FOSTER_CONTEXT,
"<boundary> cannot appear inside a table or select context",
element,
)
.element("boundary")
.help(
"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>",
)
.into()
}
#[cold]
#[inline(never)]
fn route_boundary_placement_error(&self, element: &Element<'_>) -> ParserError {
self.authoring_error_at(
codes::INVALID_ROUTE_BOUNDARY_PLACEMENT,
"route boundary must be a direct <route> child",
element,
)
.element("boundary")
.help("move the <boundary> directly under <route>, or place it in the route component template")
.into()
}
#[cold]
#[inline(never)]
fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
self.authoring_error_at(
codes::AUTHORED_WEBUI_HYDRATE,
"<webui-hydrate> is reserved for the generated runtime sentinel",
element,
)
.element("webui-hydrate")
.help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
.into()
}
fn enter_component_directive<'a>(
&mut self,
element: &Element<'a>,
fragments: &mut Vec<WebUIFragment>,
depth: usize,
ops: &mut Vec<ParseOp<'a>>,
) -> Result<()> {
let needs_template_build = !self.fragment_records.contains_key(element.name());
let cached_dom_analysis = self.component_dom_analyses.get(element.name()).copied();
let has_plugin_artifact = self
.component_registry
.component_artifact_source(element.name())
.is_some();
let authored_html = if needs_template_build || cached_dom_analysis.is_none() {
self.component_registry
.component_authored_source(element.name())
} else {
None
};
let (dom_analysis, template_source) = {
let component = self.component_registry.get(element.name()).ok_or_else(|| {
self.authoring_error_at(
codes::UNKNOWN_COMPONENT,
format!("unknown component <{}>", element.name()),
element,
)
.help(self.unknown_component_help(element.name()))
})?;
(
cached_dom_analysis.map_or_else(
|| {
let fast_html = (has_plugin_artifact
&& self.options.dom_strategy == DomStrategy::Shadow)
.then(|| {
Self::append_shadow_mode_if_missing(component.html_content.clone())
});
analyze_component_dom_with_diagnostic_source(
element.name(),
fast_html.as_deref().unwrap_or(&component.html_content),
self.options.dom_strategy,
authored_html.as_deref(),
)
},
Ok,
)?,
needs_template_build.then(|| {
(
component.html_content.clone(),
component.css_content.clone(),
)
}),
)
};
if cached_dom_analysis.is_none() {
self.component_dom_analyses
.insert(element.name().to_string(), dom_analysis);
}
self.flush_raw_buffer(fragments);
fragments.push(structural_signal(format!(
"streaming_span_start:{}",
element.name()
)));
self.add_raw_fragment("<");
self.add_raw_fragment(element.name());
if self
.component_registry
.render_policy(element.name())
.is_some_and(ComponentRenderPolicy::is_interaction_hydration)
{
self.add_raw_fragment(" data-webui-interaction");
}
let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
if let Some(ref mut p) = self.plugin {
if let Some(data) = p.finish_opening_tag(ElementStartContext {
tag_name: element.name(),
binding_count,
}) {
self.add_fragment(WebUIFragment::plugin(data), fragments);
}
}
self.flush_raw_buffer(fragments);
fragments.push(structural_signal(format!(
"streaming_root:{}",
element.name()
)));
if element.self_closing() {
self.add_raw_fragment("/>");
} else {
self.add_raw_fragment(">");
}
self.flush_raw_buffer(fragments);
if let Some((html_content, css_content)) = template_source {
let artifact_html = self
.component_registry
.component_artifact_source(element.name())
.map(str::to_string);
let built = self.build_component_templates(ComponentTemplateInput {
tag_name: element.name(),
html: &html_content,
artifact_html: artifact_html.as_deref(),
authored_html: authored_html.as_deref(),
css_content: css_content.as_deref(),
artifact_needed: self.plugin.is_some(),
})?;
if let Some(ref mut p) = self.plugin {
let component_data = self
.component_registry
.get(element.name())
.ok_or_else(|| {
ParserError::NotFound(format!(
"component <{}> disappeared during CSS compilation",
element.name()
))
})?
.clone();
p.component_built(built.plugin_context(&component_data))?;
}
self.parse(element.name(), &built.ssr)?;
}
fragments.push(WebUIFragment::component(element.name().to_string()));
if element.self_closing() {
fragments.push(structural_signal(format!(
"streaming_span_end:{}",
element.name()
)));
} else {
ops.push(ParseOp::EndComponent(element.name()));
self.enter_component_content_scope(ops);
ops.push(ParseOp::Parse {
range: element.inner(),
depth: depth + 1,
});
}
Ok(())
}
fn analyze_component_dom(
&mut self,
tag_name: &str,
source: &str,
authored_source: Option<&str>,
) -> Result<ComponentDomAnalysis> {
if let Some(analysis) = self.component_dom_analyses.get(tag_name) {
return Ok(*analysis);
}
let analysis = analyze_component_dom_with_diagnostic_source(
tag_name,
source,
self.options.dom_strategy,
authored_source,
)?;
self.component_dom_analyses
.insert(tag_name.to_string(), analysis);
Ok(analysis)
}
fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
if !Self::should_emit_text_content(content) {
return Ok(());
}
let parsed_fragments = self.handlebars_parser.parse(content)?;
for fragment in parsed_fragments {
if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
if Self::should_emit_text_content(&raw.value) {
self.add_raw_fragment(&raw.value);
}
} else if let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() {
if signal.raw && is_html_text_only_scope(self.boundary_parent_scope) {
self.add_fragment(
WebUIFragment::raw_text_signal(signal.value.as_str(), true),
fragments,
);
} else {
self.add_fragment(fragment, fragments);
}
} else {
self.add_fragment(fragment, fragments);
}
}
Ok(())
}
fn process_style_content(
&mut self,
css: &str,
comments: &[crate::css_parser::CssComment],
fragments: &mut Vec<WebUIFragment>,
) {
let mut last_end = 0usize;
for comment in comments {
if comment.start_byte < last_end {
if comment.end_byte > last_end {
last_end = comment.end_byte;
}
continue;
}
self.add_raw_fragment(&css[last_end..comment.start_byte]);
let comment_text = &css[comment.start_byte..comment.end_byte];
if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
self.add_fragment(fragment, fragments);
} else if comment.preserve {
self.add_raw_fragment(comment_text);
}
last_end = comment.end_byte;
}
self.add_raw_fragment(&css[last_end..]);
}
fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
let signal = comment_policy::parse_css_signal_comment(comment)?;
Some(WebUIFragment::raw_text_signal(signal.path, signal.raw))
}
fn extract_single_handlebars(value: &str) -> Option<&str> {
let trimmed = value.trim();
if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
let inner = trimmed[2..trimmed.len() - 2].trim();
if !inner.contains("{{") && !inner.is_empty() {
return Some(inner);
}
}
None
}
fn contains_handlebars(value: &str) -> bool {
value.contains("{{")
}
fn process_tag_attributes(
&mut self,
attrs: Attrs<'_>,
fragments: &mut Vec<WebUIFragment>,
is_component: bool,
) -> Result<u32> {
let mut first_dynamic_emitted = false;
let mut binding_count: u32 = 0;
for attr in attrs {
let attr_name = attr.name;
let attr_value = attr.value;
let is_property_binding = attr_name.starts_with(':')
&& attr_value
.and_then(Self::extract_single_handlebars)
.is_some();
if !is_property_binding {
if let Some(ref mut p) = self.plugin {
match p.process_attribute(AttributeContext { name: attr_name }) {
AttributeAction::Keep => {}
AttributeAction::Skip => continue,
AttributeAction::SkipAndCountBinding => {
binding_count += 1;
continue;
}
}
}
}
if let Some(bool_name) = attr_name.strip_prefix('?') {
if is_component {
if let Some(condition) = self.parse_boolean_condition(attr_value) {
let frag = Self::maybe_mark_attr_start(
WebUIFragment::attribute_boolean(bool_name, condition),
&mut first_dynamic_emitted,
);
self.add_fragment(frag, fragments);
binding_count += 1;
}
} else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
binding_count += 1;
}
} else if let Some(prop_name) = attr_name.strip_prefix(':') {
if !is_component {
return Err(ParserError::Parse(format!(
":{prop_name} complex binding is only allowed on custom elements. \
Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
)));
}
if Self::is_blocked_complex_property(prop_name) {
return Err(ParserError::Parse(format!(
":{prop_name} is not allowed as a complex attribute binding \
because it enables arbitrary HTML injection. \
Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
)));
}
if let Some(val) = attr_value {
if let Some(signal_name) = Self::extract_single_handlebars(val) {
let frag = Self::maybe_mark_attr_start(
WebUIFragment::attribute_complex(attr_name, signal_name),
&mut first_dynamic_emitted,
);
self.add_fragment(frag, fragments);
binding_count += 1;
}
}
} else if is_component && Self::is_skipped_attribute(attr_name) {
if let Some(val) = attr_value {
if let Some(signal_name) = Self::extract_single_handlebars(val) {
let frag = WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: attr_name.to_string(),
value: signal_name.to_string(),
attr_skip: true,
..Default::default()
},
)),
};
self.add_fragment(frag, fragments);
binding_count += 1;
} else if Self::contains_handlebars(val) {
let template_id = self.next_fragment_id("attr");
let parsed = self.parse_attribute_template(val)?;
self.fragment_records.insert(
template_id.clone(),
FragmentList {
fragments: parsed,
contains_boundary: false,
},
);
let frag = WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: attr_name.to_string(),
template: template_id,
attr_skip: true,
..Default::default()
},
)),
};
self.add_fragment(frag, fragments);
binding_count += 1;
} else {
let frag = WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: attr_name.to_string(),
value: html_escape::decode_html_entities(val).into_owned(),
raw_value: true,
attr_skip: true,
..Default::default()
},
)),
};
self.add_fragment(frag, fragments);
}
} else {
self.add_raw_fragment(" ");
self.add_raw_fragment(attr_name);
}
} else if let Some(val) = attr_value {
if Self::contains_handlebars(val) {
if is_component {
if let Some(signal_name) = Self::extract_single_handlebars(val) {
let frag = Self::maybe_mark_attr_start(
WebUIFragment::attribute(attr_name, signal_name),
&mut first_dynamic_emitted,
);
self.add_fragment(frag, fragments);
binding_count += 1;
} else {
let template_id = self.next_fragment_id("attr");
let parsed = self.parse_attribute_template(val)?;
self.fragment_records.insert(
template_id.clone(),
FragmentList {
fragments: parsed,
contains_boundary: false,
},
);
let frag = Self::maybe_mark_attr_start(
WebUIFragment::attribute_template(attr_name, template_id),
&mut first_dynamic_emitted,
);
self.add_fragment(frag, fragments);
binding_count += 1;
}
} else {
self.process_dynamic_attribute(attr_name, val, fragments)?;
binding_count += 1;
}
} else if is_component {
let frag = Self::maybe_mark_attr_start(
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: attr_name.to_string(),
value: html_escape::decode_html_entities(val).into_owned(),
raw_value: true,
..Default::default()
},
)),
},
&mut first_dynamic_emitted,
);
self.add_fragment(frag, fragments);
} else {
self.add_raw_fragment(" ");
self.add_raw_fragment(attr.raw);
}
} else if is_component {
let fragment = Self::maybe_mark_attr_start(
WebUIFragment::attribute_boolean(attr_name, ConditionExpr::identifier("true")),
&mut first_dynamic_emitted,
);
self.add_fragment(fragment, fragments);
} else {
self.add_raw_fragment(" ");
self.add_raw_fragment(attr_name);
}
}
Ok(binding_count)
}
fn parse_attribute_template(&mut self, value: &str) -> Result<Vec<WebUIFragment>> {
let mut fragments = self.handlebars_parser.parse(value)?;
for fragment in &mut fragments {
if let Some(web_ui_fragment::Fragment::Raw(raw)) = &mut fragment.fragment {
if let std::borrow::Cow::Owned(decoded) =
html_escape::decode_html_entities(&raw.value)
{
raw.value = decoded;
}
}
}
Ok(fragments)
}
fn maybe_mark_attr_start(
mut frag: WebUIFragment,
first_dynamic_emitted: &mut bool,
) -> WebUIFragment {
if !*first_dynamic_emitted {
if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
a.attr_start = true;
}
*first_dynamic_emitted = true;
}
frag
}
fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
if let Some(val) = value {
if let Some(expr_str) = Self::extract_single_handlebars(val) {
return Some(
self.condition_parser
.parse(expr_str)
.unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
);
}
}
None
}
fn process_boolean_attribute(
&mut self,
name: &str,
value: Option<&str>,
fragments: &mut Vec<WebUIFragment>,
) -> Result<bool> {
if let Some(condition) = self.parse_boolean_condition(value) {
self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
return Ok(true);
}
Ok(false)
}
fn process_dynamic_attribute(
&mut self,
name: &str,
value: &str,
fragments: &mut Vec<WebUIFragment>,
) -> Result<()> {
if let Some(signal_name) = Self::extract_single_handlebars(value) {
self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
} else {
let template_id = self.next_fragment_id("attr");
let parsed = self.handlebars_parser.parse(value)?;
self.fragment_records.insert(
template_id.clone(),
FragmentList {
fragments: parsed,
contains_boundary: false,
},
);
self.add_fragment(
WebUIFragment::attribute_template(name, template_id),
fragments,
);
}
Ok(())
}
fn process_style_element(
&mut self,
element: &Element<'_>,
fragments: &mut Vec<WebUIFragment>,
) -> Result<()> {
self.add_raw_fragment(element.opening());
let inner = element.inner();
let style_content = &element.source()[inner.start..inner.end];
let (defs, requirements, comments) = self
.css_parser
.extract_definitions_requirements_and_comments(
style_content,
self.options.legal_comments,
)
.map_err(|e| self.css_diagnostic(e))?;
self.record_fragment_css_tokens(defs, requirements);
self.process_style_content(style_content, &comments, fragments);
if element.close_end() > element.content_end() {
self.add_raw_fragment("</");
self.add_raw_fragment(element.name());
self.add_raw_fragment(">");
}
Ok(())
}
fn process_route_directive(
&mut self,
element: &Element<'_>,
fragments: &mut Vec<WebUIFragment>,
depth: usize,
) -> Result<()> {
let attrs = Self::route_attrs_from_element(element);
let path = attrs.path.clone();
let component = attrs.component.clone();
route_parser::validate_attributes(&attrs)?;
let route_params: std::collections::HashSet<String> =
route_parser::extract_params(&path)?.into_iter().collect();
if !attrs.cache_tags.is_empty() {
route_parser::validate_tag_placeholders(
&attrs.cache_tags,
&route_params,
"cache-tags",
&path,
)?;
}
if !attrs.invalidates.is_empty() {
route_parser::validate_tag_placeholders(
&attrs.invalidates,
&route_params,
"invalidates",
&path,
)?;
}
if !attrs.pending_component.is_empty() {
self.ensure_route_component_parsed(&attrs.pending_component)?;
}
if !attrs.error_component.is_empty() {
self.ensure_route_component_parsed(&attrs.error_component)?;
}
self.ensure_route_component_parsed(&component)?;
self.flush_raw_buffer(fragments);
let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
let mut all_params = std::collections::HashSet::new();
all_params.extend(route_params);
self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
let children =
self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
let mut route_fragment =
route_parser::build_route_fragment(&attrs, component.clone(), children);
route_fragment.content_fragment_id = content_fragment_id;
fragments.push(WebUIFragment::route_from(route_fragment));
Ok(())
}
fn parse_route_boundary_content(
&mut self,
route: &Element<'_>,
depth: usize,
) -> Result<String> {
let inner = route.inner();
if !route.source()[inner.clone()].contains("<boundary") {
return Ok(String::new());
}
let saved_buffer = std::mem::take(&mut self.raw_buffer);
let previous_route_depth = self.route_depth;
self.route_depth += 1;
let mut content = Vec::new();
let parse_result: Result<()> = (|| {
for event in Walker::new_range(route.source(), inner.start, inner.end) {
if let Event::Element(element) = event {
if element.name() == "boundary" {
self.parse_range(
element.source(),
element.start..element.close_end(),
&mut content,
depth + 1,
)?;
}
}
}
self.flush_raw_buffer(&mut content);
Ok(())
})();
self.route_depth = previous_route_depth;
self.raw_buffer = saved_buffer;
parse_result?;
if content.is_empty() {
return Ok(String::new());
}
let fragment_id = self.next_fragment_id("route-content");
self.fragment_records.insert(
fragment_id.clone(),
FragmentList {
fragments: content,
contains_boundary: false,
},
);
Ok(fragment_id)
}
fn parse_child_routes(
&mut self,
source: &str,
range: Range<usize>,
ancestor_params: &std::collections::HashSet<String>,
depth: usize,
) -> Result<Vec<WebUiFragmentRoute>> {
if depth > MAX_TEMPLATE_DEPTH {
return Err(self
.html_error(
codes::EXCESSIVE_NESTING,
format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
source,
range.start,
)
.help("flatten deeply nested <route> trees before building")
.into());
}
let mut children = Vec::new();
for event in Walker::new_range(source, range.start, range.end) {
match event {
Event::Element(element) => {
self.validate_closed_element(&element)?;
self.reject_boundary_or_hydrate_in_route(&element, true)?;
if element.name() == "route" {
children.push(self.parse_route_as_fragment(
&element,
ancestor_params,
depth,
)?);
} else if element.name().eq_ignore_ascii_case("style") {
self.validate_style_element(&element)?;
} else if !element.self_closing() && !element.is_void() {
self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
}
}
Event::Text(text) => {
if text.contains('<') {
return Err(self
.authoring_error(
codes::MALFORMED_HTML_TAG,
"malformed HTML tag in <route>",
)
.help("close the tag with `>`, or escape a literal `<` as `<`")
.into());
}
}
Event::Comment(comment_range) => {
self.validate_comment_range(source, comment_range)?;
}
Event::Declaration(declaration_range) => {
self.validate_declaration_range(source, declaration_range)?;
}
Event::ClosingTag(closing_range) => {
return Err(self.unexpected_closing_tag_error(source, closing_range));
}
}
}
Ok(children)
}
fn validate_route_nesting_depth(
&self,
source: &str,
range: Range<usize>,
depth: usize,
) -> Result<()> {
let mut stack = vec![(range, depth)];
while let Some((range, depth)) = stack.pop() {
if depth > MAX_TEMPLATE_DEPTH {
return Err(self
.html_error(
codes::EXCESSIVE_NESTING,
format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
source,
range.start,
)
.help("flatten deeply nested <route> trees before building")
.into());
}
for event in Walker::new_range(source, range.start, range.end) {
if let Event::Element(element) = event {
self.validate_closed_element(&element)?;
if element.name() == "route" && !element.self_closing() {
stack.push((element.inner(), depth + 1));
}
}
}
}
Ok(())
}
fn validate_ignored_route_html(
&mut self,
source: &str,
range: Range<usize>,
depth: usize,
) -> Result<()> {
let mut stack = vec![(range, depth)];
while let Some((range, depth)) = stack.pop() {
if depth > MAX_TEMPLATE_DEPTH {
return Err(self
.html_error(
codes::EXCESSIVE_NESTING,
format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
source,
range.start,
)
.help("split deeply nested markup into components, or reduce generated nesting")
.into());
}
for event in Walker::new_range(source, range.start, range.end) {
match event {
Event::Element(element) => {
self.validate_closed_element(&element)?;
self.reject_boundary_or_hydrate_in_route(&element, false)?;
if element.name().eq_ignore_ascii_case("style") {
self.validate_style_element(&element)?;
} else if !element.self_closing() && !element.is_void() {
stack.push((element.inner(), depth + 1));
}
}
Event::Text(text) => {
if text.contains('<') {
return Err(self
.authoring_error(
codes::MALFORMED_HTML_TAG,
"malformed HTML tag in <route>",
)
.help("close the tag with `>`, or escape a literal `<` as `<`")
.into());
}
}
Event::Comment(comment_range) => {
self.validate_comment_range(source, comment_range)?;
}
Event::Declaration(declaration_range) => {
self.validate_declaration_range(source, declaration_range)?;
}
Event::ClosingTag(closing_range) => {
return Err(self.unexpected_closing_tag_error(source, closing_range));
}
}
}
}
Ok(())
}
fn reject_boundary_or_hydrate_in_route(
&self,
element: &Element<'_>,
allow_boundary: bool,
) -> Result<()> {
if element.name() == "boundary" && !allow_boundary {
Err(self.route_boundary_placement_error(element))
} else if element.name().eq_ignore_ascii_case("webui-hydrate") {
Err(self.authored_webui_hydrate_error(element))
} else {
Ok(())
}
}
fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
if !element.self_closing()
&& !element.is_void()
&& element.close_end() == element.content_end()
{
return Err(self
.html_error(
codes::UNCLOSED_HTML_TAG,
format!("unclosed <{}> tag", element.name()),
element.source(),
element.start,
)
.help(format!(
"add the matching </{}> closing tag, or make the element self-closing",
element.name()
))
.into());
}
Ok(())
}
fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
let inner = element.inner();
let style_content = &element.source()[inner.start..inner.end];
self.css_parser
.extract_definitions_requirements_and_comments(
style_content,
self.options.legal_comments,
)
.map_err(|e| self.css_diagnostic(e))?;
Ok(())
}
fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
let start = range.start;
if source[range].ends_with("-->") {
return Ok(());
}
Err(self
.html_error(
codes::UNTERMINATED_HTML_COMMENT,
"unterminated HTML comment in <route>",
source,
start,
)
.help("close the comment with `-->`")
.into())
}
fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
let start = range.start;
if source[range].ends_with('>') {
return Ok(());
}
Err(self
.html_error(
codes::UNTERMINATED_HTML_DECLARATION,
"unterminated HTML declaration in <route>",
source,
start,
)
.help("close the declaration with `>`")
.into())
}
fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
let start = range.start;
let title = match html::parse_tag(&source[range]) {
Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
None => "unexpected closing tag in <route>".to_string(),
};
self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
.help("remove it, or add the matching opening tag before it")
.into()
}
fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
route_parser::RouteAttributes {
path: element.attr("path").unwrap_or_default().to_string(),
component: element.attr("component").unwrap_or_default().to_string(),
exact: element.has_attr("exact"),
query: element.attr("query").unwrap_or_default().to_string(),
keep_alive: element.has_attr("keep-alive"),
cache_tags: element
.attr("cache-tags")
.map(route_parser::parse_tag_list)
.unwrap_or_default(),
invalidates: element
.attr("invalidates")
.map(route_parser::parse_tag_list)
.unwrap_or_default(),
pending_component: element.attr("pending").unwrap_or_default().to_string(),
error_component: element.attr("error").unwrap_or_default().to_string(),
}
}
fn parse_route_as_fragment(
&mut self,
element: &Element<'_>,
ancestor_params: &std::collections::HashSet<String>,
depth: usize,
) -> Result<WebUiFragmentRoute> {
let attrs = Self::route_attrs_from_element(element);
let path = attrs.path.clone();
let component = attrs.component.clone();
route_parser::validate_attributes(&attrs)?;
let own_params: std::collections::HashSet<String> =
route_parser::extract_params(&path)?.into_iter().collect();
let mut all_params = ancestor_params.clone();
all_params.extend(own_params.iter().cloned());
if !attrs.cache_tags.is_empty() {
route_parser::validate_tag_placeholders(
&attrs.cache_tags,
&all_params,
"cache-tags",
&path,
)?;
}
if !attrs.invalidates.is_empty() {
route_parser::validate_tag_placeholders(
&attrs.invalidates,
&all_params,
"invalidates",
&path,
)?;
}
if !attrs.pending_component.is_empty() {
self.ensure_route_component_parsed(&attrs.pending_component)?;
}
if !attrs.error_component.is_empty() {
self.ensure_route_component_parsed(&attrs.error_component)?;
}
self.ensure_route_component_parsed(&component)?;
let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
let children =
self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
let mut route = route_parser::build_route_fragment(&attrs, component, children);
route.content_fragment_id = content_fragment_id;
Ok(route)
}
fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
if component.is_empty()
|| !self.component_registry.contains(component)
|| self.fragment_records.contains_key(component)
{
return Ok(());
}
let component_data = self
.component_registry
.get(component)
.ok_or_else(|| {
self.authoring_error(
codes::UNKNOWN_COMPONENT,
format!("unknown component <{component}>"),
)
.help(self.unknown_component_help(component))
})?
.clone();
let artifact_html = self
.component_registry
.component_artifact_source(component)
.map(str::to_string);
let authored_html = self.component_registry.component_authored_source(component);
let built = self.build_component_templates(ComponentTemplateInput {
tag_name: component,
html: &component_data.html_content,
artifact_html: artifact_html.as_deref(),
authored_html: authored_html.as_deref(),
css_content: component_data.css_content.as_deref(),
artifact_needed: self.plugin.is_some(),
})?;
if let Some(ref mut p) = self.plugin {
let component_data = self
.component_registry
.get(component)
.ok_or_else(|| {
ParserError::NotFound(format!(
"component <{component}> disappeared during CSS compilation"
))
})?
.clone();
p.component_built(built.plugin_context(&component_data))?;
}
let saved_buffer = std::mem::take(&mut self.raw_buffer);
self.parse(component, &built.ssr)?;
self.raw_buffer = saved_buffer;
Ok(())
}
const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-"];
const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
fn is_skipped_attribute(name: &str) -> bool {
Self::SKIPPED_ATTRIBUTES.contains(&name)
|| Self::SKIPPED_ATTRIBUTE_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
}
fn is_blocked_complex_property(name: &str) -> bool {
matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
}
fn build_component_templates(
&mut self,
input: ComponentTemplateInput<'_>,
) -> Result<BuiltComponentTemplate> {
let tag_name = input.tag_name;
let synthesize_fast_shadow =
input.artifact_html.is_some() && self.options.dom_strategy == DomStrategy::Shadow;
let fast_html = synthesize_fast_shadow
.then(|| Self::append_shadow_mode_if_missing(input.html.to_string()));
let html = fast_html.as_deref().unwrap_or(input.html);
let css_content = input.css_content;
let dom_analysis = self.analyze_component_dom(tag_name, html, input.authored_html)?;
let is_light = !dom_analysis.uses_shadow_dom;
let mut inline_style_ranges = Vec::new();
if is_light {
let mut comment_ranges = Vec::new();
Self::collect_html_comment_and_style_ranges(
html,
&mut comment_ranges,
&mut inline_style_ranges,
)?;
if let Some(css) = css_content {
css_light::validate_global_css(tag_name, css)?;
}
for (start, end) in &inline_style_ranges {
css_light::validate_global_css(tag_name, &html[*start..*end])?;
}
}
if self.component_registry.contains(tag_name) {
self.component_registry
.prepare_policy_css(tag_name, dom_analysis.uses_shadow_dom)?;
}
let css_content = self
.component_registry
.get(tag_name)
.and_then(|component| component.css_content.as_deref())
.or(css_content);
let adopted_specifier = match self.options.css_strategy {
CssStrategy::Module if css_content.is_some() => Some(tag_name),
_ => None,
};
let style = if dom_analysis.uses_shadow_dom && self.plugin.is_some() {
self.component_style_delivery(tag_name, css_content)
} else {
None
};
let runtime_attr_source = match dom_analysis.authored_shadow_root {
Some((start, end)) => &html[start..end],
None => html,
};
let artifact_source = if input.artifact_needed {
input.artifact_html.or_else(|| {
Self::template_has_stripped_runtime_attrs(runtime_attr_source).then_some(html)
})
} else {
None
};
let policy_wrapper = parse_component_render_policy(tag_name, html)?.is_authored();
let preserve_root_bindings = self.component_processing.process_root_template_attributes;
let styles_at_end = matches!(self.options.css_strategy, CssStrategy::Style)
&& self.component_processing.inline_styles_after_content;
let ssr = self.process_component_template_with_mode(
html,
ComponentStyleInjection {
css_snippet: None,
adopted_specifier,
},
dom_analysis,
ComponentTemplateMode {
preserve_runtime_attrs: preserve_root_bindings,
policy_wrapper,
styles_at_end,
},
)?;
let artifact = match artifact_source {
Some(source) => {
let fast_artifact = synthesize_fast_shadow
.then(|| Self::append_shadow_mode_if_missing(source.to_string()));
let source = fast_artifact.as_deref().unwrap_or(source);
let artifact_dom_analysis = analyze_component_dom_with_diagnostic_source(
tag_name,
source,
self.options.dom_strategy,
input.authored_html,
)?;
Some(self.process_component_template_with_mode(
source,
ComponentStyleInjection {
css_snippet: None,
adopted_specifier,
},
artifact_dom_analysis,
ComponentTemplateMode {
preserve_runtime_attrs: true,
policy_wrapper,
styles_at_end,
},
)?)
}
None => None,
};
Ok(BuiltComponentTemplate {
ssr,
artifact,
uses_shadow_dom: dom_analysis.uses_shadow_dom,
style,
})
}
fn component_style_delivery(
&self,
tag_name: &str,
css_content: Option<&str>,
) -> Option<OwnedComponentStyle> {
let css = css_content?;
Some(match self.options.css_strategy {
CssStrategy::Link => {
OwnedComponentStyle::Link(self.options.css_link_options.resolve(tag_name, css).href)
}
CssStrategy::Style => OwnedComponentStyle::Inline(css.trim().to_string()),
CssStrategy::Module => OwnedComponentStyle::Adopted(tag_name.to_string()),
})
}
#[cfg(test)]
fn process_component_template(
&mut self,
html: &str,
css_snippet: Option<&str>,
adopted_specifier: Option<&str>,
) -> Result<String> {
let dom_analysis = analyze_component_dom("component", html, self.options.dom_strategy)?;
self.process_component_template_with_mode(
html,
ComponentStyleInjection {
css_snippet,
adopted_specifier,
},
dom_analysis,
ComponentTemplateMode {
preserve_runtime_attrs: false,
policy_wrapper: false,
styles_at_end: false,
},
)
}
fn process_component_template_with_mode(
&mut self,
html: &str,
style: ComponentStyleInjection<'_>,
dom_analysis: ComponentDomAnalysis,
mode: ComponentTemplateMode,
) -> Result<String> {
let trimmed_end = html.trim_end();
let (trimmed, content_start) = html::leading_content(trimmed_end);
let snippet = style.css_snippet.unwrap_or_default();
let processed = if let Some((root_start, root_end)) = dom_analysis.authored_shadow_root {
let trim_start = content_start;
let trim_end = html.trim_end().len();
if root_start < trim_start || root_end > trim_end || root_start >= root_end {
return Err(ParserError::Html(
"invalid declarative Shadow DOM source range".to_string(),
));
}
let root = &html[root_start..root_end];
let base = self.strip_template_build_attrs(root, mode.preserve_runtime_attrs);
let with_adopted = Self::append_adopted_attr_if_missing(base, style.adopted_specifier);
let root =
Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end);
let mut result =
String::with_capacity(trimmed.len() + root.len() - (root_end - root_start));
result.push_str(&html[trim_start..root_start]);
result.push_str(&root);
result.push_str(&html[root_end..trim_end]);
result
} else if let Some((content_start, content_end)) = dom_analysis.authored_light_root {
let base = html[content_start..content_end].to_string();
if snippet.is_empty() {
base
} else {
let mut result = String::with_capacity(snippet.len() + base.len());
result.push_str(snippet);
result.push_str(&base);
result
}
} else if dom_analysis.uses_shadow_dom {
if mode.policy_wrapper {
let base = self.strip_template_build_attrs(trimmed, mode.preserve_runtime_attrs);
let with_shadow = Self::append_shadow_mode_if_missing(base);
let with_adopted =
Self::append_adopted_attr_if_missing(with_shadow, style.adopted_specifier);
Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end)
} else {
let adopted = style.adopted_specifier.unwrap_or_default();
let adopted_extra = if adopted.is_empty() {
0
} else {
Self::adopted_attr_len(adopted)
};
let mut result =
String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
result.push_str("<template shadowrootmode=\"open\"");
if !adopted.is_empty() {
Self::push_adopted_attr(&mut result, adopted);
}
result.push('>');
if mode.styles_at_end {
result.push_str(trimmed);
result.push_str(snippet);
} else {
result.push_str(snippet);
result.push_str(trimmed);
}
result.push_str("</template>");
result
}
} else {
let base = if mode.policy_wrapper {
Self::unwrap_component_template(trimmed)
} else {
trimmed.to_string()
};
if snippet.is_empty() {
base
} else {
let mut result = String::with_capacity(snippet.len() + base.len());
result.push_str(snippet);
result.push_str(&base);
result
}
};
self.strip_template_comments(processed)
}
fn inject_css_snippet_into_template(html: String, snippet: &str, at_end: bool) -> String {
if snippet.is_empty() {
return html;
}
if at_end {
if let Some(close_start) = html.rfind("</template>") {
let mut result = String::with_capacity(html.len() + snippet.len());
result.push_str(&html[..close_start]);
result.push_str(snippet);
result.push_str(&html[close_start..]);
return result;
}
}
match html::find_tag_close(&html) {
Some(open_end) => {
let mut result = String::with_capacity(html.len() + snippet.len());
result.push_str(&html[..=open_end]);
result.push_str(snippet);
result.push_str(&html[open_end + 1..]);
result
}
None => html,
}
}
fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
let Some(adopted) = adopted_specifier else {
return html;
};
let Some(tag) = html::parse_tag(&html) else {
return html;
};
if !tag.name.eq_ignore_ascii_case("template")
|| tag.closing
|| tag.attrs().any(|attr| {
attr.name
.eq_ignore_ascii_case(Self::ADOPTED_STYLESHEETS_ATTR)
})
{
return html;
}
let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
result.push_str(&html[..tag.close]);
Self::push_adopted_attr(&mut result, adopted);
result.push_str(&html[tag.close..]);
result
}
fn append_shadow_mode_if_missing(html: String) -> String {
let Some(tag) = html::parse_tag(&html) else {
return html;
};
if !tag.name.eq_ignore_ascii_case("template")
|| tag.closing
|| tag
.attrs()
.any(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
{
return html;
}
let mut result = String::with_capacity(html.len() + 22);
result.push_str(&html[..tag.close]);
result.push_str(" shadowrootmode=\"open\"");
result.push_str(&html[tag.close..]);
result
}
fn unwrap_component_template(html: &str) -> String {
let Some(tag) = html::parse_tag(html) else {
return html.to_string();
};
let inner_start = tag.close + 1;
let inner_end = html.rfind("</template>").unwrap_or(html.len());
if inner_start >= inner_end {
return String::new();
}
html[inner_start..inner_end].to_string()
}
fn push_adopted_attr(out: &mut String, adopted: &str) {
out.push(' ');
out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
out.push_str("=\"");
out.push_str(adopted);
out.push('"');
}
fn adopted_attr_len(adopted: &str) -> usize {
4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
}
fn template_has_stripped_runtime_attrs(html: &str) -> bool {
let (trimmed, _) = html::leading_content(html);
let Some(tag) = html::parse_tag(trimmed) else {
return false;
};
if !tag.name.eq_ignore_ascii_case("template") || tag.closing {
return false;
}
tag.attrs().any(|attr| {
attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
})
}
fn strip_template_comments(&mut self, html: String) -> Result<String> {
let mut ranges = Vec::new();
let mut style_ranges = Vec::new();
Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
for (style_start, style_end) in style_ranges {
let css = &html[style_start..style_end];
let (_defs, _requirements, comments) = self
.css_parser
.extract_definitions_requirements_and_comments(css, self.options.legal_comments)
.map_err(|e| self.css_diagnostic(e))?;
for comment in comments {
let comment_text = &css[comment.start_byte..comment.end_byte];
if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
continue;
}
ranges.push((
style_start + comment.start_byte,
style_start + comment.end_byte,
));
}
}
if ranges.is_empty() {
return Ok(html);
}
Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
}
fn collect_html_comment_and_style_ranges(
html: &str,
comment_ranges: &mut Vec<(usize, usize)>,
style_ranges: &mut Vec<(usize, usize)>,
) -> Result<()> {
let mut index = 0usize;
while index < html.len() {
let remaining = &html[index..];
if remaining.starts_with("<!--") {
let Some(end) = html::find_comment_close(remaining) else {
return Err(Diagnostic::error("unterminated HTML comment")
.code(codes::UNTERMINATED_HTML_COMMENT)
.at_offset(html, index)
.help("close the comment with `-->`")
.into());
};
comment_ranges.push((index, index + end));
index += end;
continue;
}
if remaining.starts_with('<') {
if let Some((style_start, style_end, close_end)) =
html::style_element_bounds(remaining)
{
style_ranges.push((index + style_start, index + style_end));
index += close_end;
continue;
}
if let Some(tag) = html::parse_tag(remaining) {
index += tag.close + 1;
continue;
}
}
let Some(ch) = remaining.chars().next() else {
break;
};
index += ch.len_utf8();
}
Ok(())
}
fn strip_template_build_attrs(&mut self, html: &str, preserve_runtime_attrs: bool) -> String {
let Some(tag) = html::parse_tag(html) else {
return html.to_string();
};
let mut removals: Vec<(usize, usize)> = Vec::new();
for attr in tag.attrs() {
let runtime_attr = attr.name.starts_with('@')
|| attr.name.starts_with(':')
|| attr.name.starts_with('?');
let policy_attr = matches!(
attr.name,
COMPONENT_RENDER_ATTR | COMPONENT_HYDRATE_ATTR | COMPONENT_RESERVE_BLOCK_SIZE_ATTR
);
if policy_attr || (!preserve_runtime_attrs && runtime_attr) {
let mut start = attr.raw_range.start;
while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
start -= 1;
}
removals.push((start, attr.raw_range.end));
}
}
if removals.is_empty() {
return html.to_string();
}
let mut result = String::with_capacity(html.len());
let mut pos = 0;
for (start, end) in &removals {
result.push_str(&html[pos..*start]);
pos = *end;
}
result.push_str(&html[pos..]);
result
}
}
#[cfg(test)]
mod tests {
use webui_test_utils::*;
use super::*;
#[derive(Default)]
struct ContextArtifactPlugin {
artifacts: Vec<crate::plugin::ComponentTemplateArtifact>,
}
impl ParserPlugin for ContextArtifactPlugin {
fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
self.artifacts
.push(crate::plugin::ComponentTemplateArtifact::template(
context.component.tag_name.clone(),
context.template.to_string(),
context.uses_shadow_dom,
));
Ok(())
}
fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
Ok(ParserPluginArtifacts::ComponentTemplates(self.artifacts))
}
}
fn structural_matcher(value: &str) -> FragmentMatcher {
signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
}
mod fast_parser;
fn boundary_matcher(name: &str, declaration: u32) -> FragmentMatcher {
webui_test_utils::boundary(name, declaration)
}
fn boundary_end_matcher(declaration: u32) -> FragmentMatcher {
webui_test_utils::boundary_end(declaration)
}
#[test]
fn unwrapped_component_uses_configured_dom_strategy() {
assert!(
analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Shadow)
.expect("valid component")
.uses_shadow_dom
);
assert!(
!analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Light)
.expect("valid component")
.uses_shadow_dom
);
}
#[test]
fn bare_template_explicitly_selects_light_dom_over_shadow_fallback() {
let analysis = analyze_component_dom(
"x-card",
"<template><div>content</div></template>",
DomStrategy::Shadow,
)
.expect("valid explicit Light wrapper");
assert!(!analysis.uses_shadow_dom);
assert!(analysis.authored_light_root.is_some());
let attributed = analyze_component_dom(
"x-card",
r#"<template data-purpose="content"><div>content</div></template>"#,
DomStrategy::Shadow,
)
.expect("ordinary template remains valid");
assert!(attributed.uses_shadow_dom);
assert!(attributed.authored_light_root.is_none());
}
#[test]
fn plugin_artifacts_preserve_resolved_shadow_mode() {
let component_html = r#"<template shadowrootmode="open"><p>shadow</p></template>"#;
{
let mut parser = HtmlParser::with_plugin_options(
Box::new(ContextArtifactPlugin::default()),
CssStrategy::Style,
);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"x-card",
component_html,
None,
false,
))
.expect("register component");
parser
.parse("index.html", "<x-card></x-card>")
.expect("parse component");
let ParserPluginArtifacts::ComponentTemplates(artifacts) =
parser.take_plugin_artifacts().expect("consume artifacts")
else {
panic!("expected component artifacts");
};
assert!(artifacts[0].uses_shadow_dom);
}
}
#[test]
fn open_shadow_wrapper_marks_component_shadow() {
let html =
"<!-- lead --><template shadowrootmode='open'><slot></slot></template><!-- tail -->";
assert!(
analyze_component_dom("x-card", html, DomStrategy::Light)
.expect("valid Shadow wrapper")
.uses_shadow_dom
);
}
#[test]
fn closed_shadow_wrapper_is_rejected() {
let error = analyze_component_dom(
"x-card",
"<template shadowrootmode=\"closed\"><p>content</p></template>",
DomStrategy::Shadow,
)
.expect_err("closed mode must fail");
assert!(matches!(
error,
ParserError::Template(ref diagnostic)
if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
&& diagnostic.component_name() == Some("x-card")
));
}
#[test]
fn shadow_wrapper_must_be_the_only_top_level_element() {
let error = analyze_component_dom(
"x-card",
"<template shadowrootmode=\"open\"><p>content</p></template><span>extra</span>",
DomStrategy::Light,
)
.expect_err("multiple roots must fail");
assert!(matches!(
error,
ParserError::Template(ref diagnostic)
if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
));
}
#[test]
fn shadowrootmode_rejects_invalid_value_and_placement() {
for html in [
"<template shadowrootmode=\"invalid\"><p>content</p></template>",
"<template shadowrootmode=\" open \"><p>content</p></template>",
"<template :shadowrootmode=\"{{mode}}\"><p>content</p></template>",
"<template shadowrootmode=\"open\" />",
"<div><template shadowrootmode=\"open\"><p>content</p></template></div>",
"<div shadowrootmode=\"open\">content</div>",
] {
let error = analyze_component_dom("x-card", html, DomStrategy::Shadow)
.expect_err("invalid shadowrootmode must fail");
assert!(matches!(
error,
ParserError::Template(ref diagnostic)
if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
&& diagnostic.component_name() == Some("x-card")
&& diagnostic.help_text().is_some()
&& diagnostic.snippet_text().is_some()
));
}
}
#[test]
fn light_component_slot_is_rejected() {
let source = "<div><slot name=\"label\"></slot></div>";
let error = analyze_component_dom("x-card", source, DomStrategy::Light)
.expect_err("Light slot must fail");
assert!(matches!(
error,
ParserError::Template(ref diagnostic)
if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
&& diagnostic.help_text().is_some_and(|help| help.contains("shadowrootmode"))
));
assert!(
analyze_component_dom("x-card", source, DomStrategy::Shadow)
.expect("implicit Shadow components support slots")
.uses_shadow_dom
);
let explicit_light = "<template><slot name=\"label\"></slot></template>";
let error = analyze_component_dom("x-card", explicit_light, DomStrategy::Shadow)
.expect_err("bare template explicitly selects Light");
assert!(matches!(
error,
ParserError::Template(ref diagnostic)
if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
));
}
#[test]
fn webui_plugin_records_authored_shadow_wrapper() {
let mut parser =
HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"x-card",
"<!-- lead --><template shadowrootmode=\"open\" @click=\"{onClick()}\"><slot></slot></template><!-- tail -->",
None,
true,
))
.expect("register component");
parser
.parse("index.html", "<x-card>projected</x-card>")
.expect("parse component");
let ParserPluginArtifacts::ComponentTemplates(templates) =
parser.take_plugin_artifacts().expect("plugin artifacts")
else {
panic!("expected component templates");
};
assert!(templates[0].uses_shadow_dom);
assert!(templates[0].template_json.contains("\"sd\":1"));
assert!(templates[0]
.template_json
.contains(r#""re":[["click","onClick",[]]]"#));
}
struct StyleContextPlugin {
captured: std::rc::Rc<std::cell::RefCell<Vec<(bool, Option<String>, String)>>>,
}
impl crate::plugin::ParserPlugin for StyleContextPlugin {
fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
self.captured.borrow_mut().push((
context.uses_shadow_dom,
context.style.map(|style| format!("{style:?}")),
context.template.to_string(),
));
Ok(())
}
}
fn capture_style_context(
strategy: CssStrategy,
dom_strategy: DomStrategy,
template: &str,
css: Option<&str>,
) -> (bool, Option<String>, String) {
let captured = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let mut parser = HtmlParser::with_plugin_options(
Box::new(StyleContextPlugin {
captured: std::rc::Rc::clone(&captured),
}),
ParserOptions {
css_strategy: strategy,
dom_strategy,
..ParserOptions::default()
},
);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new("x-card", template, css, true))
.expect("register component");
parser
.parse("index.html", "<x-card></x-card>")
.expect("parse component");
let captured = captured.borrow();
captured.first().cloned().expect("component registered")
}
#[test]
fn parser_reports_shadow_style_delivery_without_injecting_it() {
for (strategy, expected) in [
(CssStrategy::Link, "Link { href: \"x-card.css\" }"),
(CssStrategy::Style, "Inline { css: \".card{color:red}\" }"),
(CssStrategy::Module, "Adopted { specifier: \"x-card\" }"),
] {
let (uses_shadow_dom, style, template) = capture_style_context(
strategy,
DomStrategy::Shadow,
"<template shadowrootmode=\"open\" shadowrootadoptedstylesheets=\"x-card\"><div class=\"card\"></div></template>",
Some(".card{color:red}"),
);
assert!(uses_shadow_dom, "{strategy:?} should be shadow");
assert_eq!(style.as_deref(), Some(expected), "{strategy:?} delivery");
assert!(
!template.contains("<style>") && !template.contains("<link"),
"{strategy:?} template must stay injection-free: {template}"
);
}
}
#[test]
fn parser_reports_no_style_delivery_for_light_components() {
let (uses_shadow_dom, style, _) = capture_style_context(
CssStrategy::Style,
DomStrategy::Light,
"<div class=\"card\"></div>",
Some(".card{color:red}"),
);
assert!(!uses_shadow_dom);
assert_eq!(style, None);
}
#[test]
fn parser_reports_no_style_delivery_without_css() {
let (uses_shadow_dom, style, _) = capture_style_context(
CssStrategy::Style,
DomStrategy::Shadow,
"<template shadowrootmode=\"open\"><div></div></template>",
None,
);
assert!(uses_shadow_dom);
assert_eq!(style, None);
}
#[test]
fn test_plugin_display_names() {
assert_eq!(Plugin::Fast.to_string(), "fast");
assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
assert_eq!(Plugin::WebUI.to_string(), "webui");
}
#[test]
fn test_plugin_from_str_names() {
assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
}
#[test]
fn test_plugin_from_str_unknown_mentions_supported_names() {
let err = "legacy".parse::<Plugin>().unwrap_err();
assert!(
err.contains("fast-v3"),
"error should mention fast-v3: {err}"
);
assert!(
err.contains("fast-v2"),
"error should mention fast-v2: {err}"
);
assert!(err.contains("fast"), "error should mention fast: {err}");
}
#[test]
fn test_parse_signal() {
let mut parser = HtmlParser::new();
let html = "Hello, {{name}}!";
let result = parser.parse("test.html", html);
assert!(result.is_ok());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[raw("Hello, "), signal("name"), raw("!"),]
);
}
#[test]
fn test_parse_raw_signal() {
let mut parser = HtmlParser::new();
let html = "Hello, {{{html_content}}}!";
let result = parser.parse("test.html", html);
assert!(result.is_ok());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[raw("Hello, "), signal_raw("html_content"), raw("!"),]
);
}
#[test]
fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
let mut parser = HtmlParser::new();
let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<nav>"),
signal("sectionName"),
raw(" > "),
signal("topicName"),
raw("</nav>"),
]
);
}
#[test]
fn test_invalid_for_each_is_template_diagnostic() {
let mut parser = HtmlParser::new();
let err = parser
.parse(
"index.html",
"<div>\n <for each=\"person inpeople\"><p>x</p></for>\n</div>",
)
.expect_err("invalid for-each must error");
assert!(matches!(err, ParserError::Template(_)));
let msg = err.to_string();
assert!(msg.contains("invalid <for> each expression"), "{msg}");
assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
assert!(
msg.contains("--> index.html:2:3"),
"missing line:column — {msg}"
);
assert!(msg.contains("help:"), "{msg}");
}
#[test]
fn test_invalid_if_condition_is_template_diagnostic() {
let mut parser = HtmlParser::new();
let err = parser
.parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
.expect_err("invalid condition must error");
assert!(matches!(err, ParserError::Template(_)));
let msg = err.to_string();
assert!(msg.contains("invalid <if> condition expression"), "{msg}");
assert!(
msg.contains("--> index.html:1:1"),
"missing line:column — {msg}"
);
}
#[test]
fn test_parse_for_directive() {
let mut parser = HtmlParser::new();
let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[for_loop("item", "items", "for-1"),]
);
assert_stream!(
fragment_records,
"for-1",
[
raw("<div class=\"item\">"),
signal("item.name"),
raw("</div>"),
]
);
}
#[test]
fn test_parse_if_directive() {
let mut parser = HtmlParser::new();
let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
assert_stream!(
fragment_records,
"if-1",
[
raw("<div>Welcome back, "),
signal("username"),
raw("!</div>"),
]
);
}
#[test]
fn test_component_directive() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-component",
"<div>My Component</div>",
Some("div { color: blue; }"),
true,
))
.expect("Failed to register component");
let result = parser.parse("test.html", "<my-component></my-component>");
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
assert_stream!(
records,
"test.html",
[
raw("<my-component"),
structural_matcher("streaming_root:my-component"),
raw(">"),
component("my-component"),
raw("</my-component>"),
]
);
let comp = &records["my-component"].fragments;
assert_eq!(comp.len(), 1);
assert!(
matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
!raw.value.contains("<template shadowrootmode")
&& raw.value.contains("<div")
&& raw.value.contains(">My Component</div>"))
);
}
#[test]
fn shadow_component_emits_style_hook_inside_declarative_root() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-component",
r#"<template shadowrootmode="open"><div>My Component</div></template>"#,
Some("div { color: blue; }"),
true,
))
.expect("register");
parser
.parse("test.html", "<my-component></my-component>")
.expect("parse");
let records = parser.into_fragment_records();
assert_stream!(
records,
"my-component",
[
raw("<template shadowrootmode=\"open\">"),
structural_matcher("shadow_styles:my-component"),
raw("<div>My Component</div></template>"),
]
);
assert!(
records["my-component"]
.fragments
.iter()
.all(|fragment| !matches!(
fragment.fragment.as_ref(),
Some(Fragment::Raw(raw)) if raw.value.contains("color: blue")
)),
"component CSS is delivered by the handler closure"
);
}
#[test]
fn authored_shadow_attribute_name_is_ascii_case_insensitive_for_style_hook() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-component",
r#"<template shadowRootMode="open"><slot></slot></template>"#,
None,
true,
))
.expect("register");
parser
.parse("test.html", "<my-component></my-component>")
.expect("parse");
let records = parser.into_fragment_records();
assert_stream!(
records,
"my-component",
[
raw(r#"<template shadowRootMode="open">"#),
structural_matcher("shadow_styles:my-component"),
raw("<slot></slot></template>"),
]
);
}
#[test]
fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-widget",
"<div>w</div>",
None,
true,
))
.expect("register");
let result = parser.parse("test.html", "<my-widget></my-widget>");
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
assert_stream!(
records,
"test.html",
[
raw("<my-widget"),
structural_matcher("streaming_root:my-widget"),
raw(">"),
component("my-widget"),
raw("</my-widget>"),
]
);
}
#[test]
fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
use webui_protocol::web_ui_fragment::Fragment;
let mut parser = HtmlParser::new();
for name in ["outer-box", "inner-pill"] {
parser
.component_registry
.register_component(ComponentRegistration::new(
name,
"<div>content</div>",
None,
true,
))
.expect("register");
}
let result = parser.parse(
"test.html",
"<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
let fragments = &records["test.html"].fragments;
let signals: Vec<&str> = fragments
.iter()
.filter_map(|f| match f.fragment.as_ref() {
Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
_ => None,
})
.collect();
assert_eq!(
signals,
[
"}}}webui:streaming_root:outer-box",
"}}}webui:streaming_root:inner-pill",
"}}}webui:streaming_root:inner-pill",
]
);
for (i, frag) in fragments.iter().enumerate() {
let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
continue;
};
if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
continue;
}
match fragments[i + 1].fragment.as_ref() {
Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
other => panic!("expected raw(\">\") after signal, got {other:?}"),
}
}
}
#[test]
fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"mp-button",
"<button>b</button>",
None,
true,
))
.expect("Failed to register component");
let err = parser
.parse("test.html", "<mp-buton></mp-buton>")
.expect_err("a near-typo component tag should error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
let help = diag
.help_text()
.expect("diagnostic should carry a help line");
assert!(
help.contains("did you mean <mp-button>?"),
"expected a suggestion, got: {help}"
);
}
#[test]
fn external_custom_element_in_other_namespace_passes_through() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"mp-button",
"<button>b</button>",
None,
true,
))
.expect("Failed to register component");
let result = parser.parse("test.html", "<md-button></md-button>");
assert!(
result.is_ok(),
"external custom element should pass through, got: {:?}",
result.err()
);
}
#[test]
fn for_missing_each_suggests_typoed_attribute() {
let mut parser = HtmlParser::new();
let err = parser
.parse("test.html", "<for eahc=\"todo in todos\"></for>")
.expect_err("a <for> without `each` should error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
let help = diag
.help_text()
.expect("diagnostic should carry a help line");
assert!(
help.contains("found `eahc`") && help.contains("did you mean"),
"expected an attribute-typo suggestion, got: {help}"
);
}
#[test]
fn parent_diagnostic_owner_survives_nested_component_parse() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-card",
"<div>card</div>",
None,
true,
))
.expect("register");
let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
let err = parser
.parse("index.html", html)
.expect_err("the malformed <for> should error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
assert_eq!(
diag.component_name(),
Some("index.html"),
"parent diagnostic must be owned by index.html, not the nested component"
);
}
#[test]
fn test_component_directive_with_slots() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-component",
"<div>My Component</div>",
Some("div { color: blue; }"),
true,
))
.expect("Failed to register component");
let result = parser.parse(
"test.html",
"Hello<my-component><p>World</p></my-component>",
);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
let fragments = &records["test.html"].fragments;
assert!(fragments.len() >= 3);
assert!(
matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
);
assert!(fragments.iter().any(|f| matches!(
f.fragment.as_ref(),
Some(Fragment::Component(c)) if c.fragment_id == "my-component"
)));
let last = fragments.last().unwrap();
assert!(
matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
);
}
#[test]
fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"app-shell",
r#"<template shadowrootmode="open">
<my-navbar></my-navbar>
<main><outlet /></main>
<cart-panel></cart-panel>
<my-footer></my-footer>
</template>"#,
Some(":host{display:flex}"),
true,
))
.expect("register app-shell");
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-navbar",
"<nav>Nav</nav>",
Some("nav{color:red}"),
true,
))
.expect("register my-navbar");
parser
.component_registry
.register_component(ComponentRegistration::new(
"cart-panel",
"<aside>Cart</aside>",
Some("aside{color:green}"),
true,
))
.expect("register cart-panel");
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-footer",
"<footer>Footer</footer>",
Some("footer{color:blue}"),
true,
))
.expect("register my-footer");
parser
.parse("index.html", "<app-shell></app-shell>")
.expect("parse index.html");
let records = parser.into_fragment_records();
let app_frags = &records["app-shell"].fragments;
let component_ids: Vec<&str> = app_frags
.iter()
.filter_map(|f| match f.fragment.as_ref() {
Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
_ => None,
})
.collect();
assert!(
component_ids.contains(&"my-navbar"),
"app-shell fragments should contain my-navbar: {component_ids:?}"
);
assert!(
component_ids.contains(&"cart-panel"),
"app-shell fragments should contain cart-panel: {component_ids:?}"
);
assert!(
component_ids.contains(&"my-footer"),
"app-shell fragments should contain my-footer: {component_ids:?}"
);
}
#[test]
fn test_component_no_double_wrap_template() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
r#"<template foo="bar"><div>content</div></template>"#,
None,
true,
))
.expect("register");
let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[
raw("<custom-element"),
structural_matcher("streaming_root:custom-element"),
raw(">"),
component("custom-element"),
raw("Hello</custom-element>"),
]
);
assert_stream!(
records,
"custom-element",
[raw(r#"<template foo="bar"><div>content</div></template>"#),]
);
}
#[test]
fn test_component_styled_no_double_wrap() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
r#"<template foo="bar"><div>content</div></template>"#,
Some("div { color: red; }"),
true,
))
.expect("register");
let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_stream!(
records,
"custom-element",
[raw(r#"<template foo="bar"><div>content</div></template>"#),]
);
}
#[test]
fn test_component_strip_runtime_attrs() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
r#"<template shadowrootmode="open" @click={foo} :bar="baz" ?bool="true"><div>content</div></template>"#,
None,
true,
))
.expect("register");
let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_stream!(
records,
"custom-element",
[
raw(r#"<template shadowrootmode="open">"#),
structural_matcher("shadow_styles:custom-element"),
raw("<div>content</div></template>"),
]
);
}
#[test]
fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
r#"<template shadowrootmode="open" data-note="@click={foo}" @click={foo}><div>content</div></template>"#,
None,
true,
))
.expect("register");
let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_stream!(
records,
"custom-element",
[
raw(r#"<template shadowrootmode="open" data-note="@click={foo}">"#),
structural_matcher("shadow_styles:custom-element"),
raw("<div>content</div></template>"),
]
);
}
#[test]
fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
r#"<template shadowrootmode="open" @click={foo} @click={bar}><div>content</div></template>"#,
None,
true,
))
.expect("register");
let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_stream!(
records,
"custom-element",
[
raw(r#"<template shadowrootmode="open">"#),
structural_matcher("shadow_styles:custom-element"),
raw("<div>content</div></template>"),
]
);
}
#[test]
fn test_component_with_slots_and_attrs() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
r#"<template shadowrootmode="open"><slot></slot></template>"#,
None,
true,
))
.expect("register");
let result = parser.parse(
"index.html",
r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
);
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[
raw("<custom-element"),
attr_raw_start("appearance", "subtle"),
structural_matcher("streaming_root:custom-element"),
raw(">"),
component("custom-element"),
raw("Hello World</custom-element>"),
]
);
}
#[test]
fn test_component_legacy_no_styles() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
"<div>Custom Element</div>",
None,
true,
))
.expect("register");
let result = parser.parse("index.html", "<custom-element></custom-element>");
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[
raw("<custom-element"),
structural_matcher("streaming_root:custom-element"),
raw(">"),
component("custom-element"),
raw("</custom-element>"),
]
);
assert_stream!(
records,
"custom-element",
[raw("<div>Custom Element</div>"),]
);
}
#[test]
fn test_component_self_closing() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-widget",
"<div>Widget Content</div>",
None,
true,
))
.expect("register");
let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[
raw("<custom-widget"),
attr_start("config", "settings"),
structural_matcher("streaming_root:custom-widget"),
raw("/>"),
component("custom-widget"),
]
);
}
#[test]
fn test_component_nested_self_closing_in_slot() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-icon",
"<template shadowrootmode=\"open\"><svg><slot></slot></svg></template>",
None,
true,
))
.expect("register");
let result = parser.parse(
"index.html",
r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
);
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[
raw("<custom-icon"),
structural_matcher("streaming_root:custom-icon"),
raw(">"),
component("custom-icon"),
raw("<use"),
attr_template("href", "attr-1"),
raw("/></custom-icon>"),
]
);
assert_stream!(
records,
"custom-icon",
[
raw("<template shadowrootmode=\"open\">"),
structural_matcher("shadow_styles:custom-icon"),
raw("<svg><slot></slot></svg></template>"),
]
);
}
#[test]
fn test_component_leading_boolean_attr_start() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
r#"<template shadowrootmode="open"><slot></slot></template>"#,
None,
true,
))
.expect("register");
let result = parser.parse(
"index.html",
r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
);
assert!(result.is_ok());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[
raw("<custom-element"),
bool_attr_start("disabled", "isDisabled"),
attr_raw("title", "Hello"),
structural_matcher("streaming_root:custom-element"),
raw(">"),
component("custom-element"),
raw("</custom-element>"),
]
);
}
#[test]
fn test_component_boolean_predicate_preserves_condition_tree() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
r#"<template shadowrootmode="open"><slot></slot></template>"#,
None,
true,
))
.expect("register");
let result = parser.parse(
"index.html",
r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
);
assert!(result.is_ok());
let records = parser.into_fragment_records();
let fragments = &records["index.html"].fragments;
match fragments
.get(1)
.and_then(|fragment| fragment.fragment.as_ref())
{
Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
assert_eq!(attr.name, "disabled");
assert!(attr.attr_start);
match attr
.condition_tree
.as_ref()
.and_then(|condition| condition.expr.as_ref())
{
Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
assert_eq!(pred.left, "page");
assert_eq!(pred.operator, 3);
assert_eq!(pred.right, "'dashboard'");
}
other => panic!("expected predicate condition tree, got {:?}", other),
}
}
other => panic!("expected attribute fragment, got {:?}", other),
}
}
#[test]
fn test_component_meta_link_tags() {
let (fragments, _) = parse_and_get_fragments(
r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
);
assert!(fragments.len() >= 7);
assert!(
matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
);
assert!(
matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
);
assert!(
matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
);
assert!(
matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
);
assert!(
matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
);
assert!(
matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
);
assert!(
matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
);
}
#[test]
fn test_nested_directives() {
let mut parser = HtmlParser::new();
let html = r#"<for each="category in categories">
<if condition="category.hasItems">
<for each="item in category.items">
{{item.title}}
</for>
</if>
</for>"#;
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[for_loop("category", "categories", "for-1"),]
);
assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
assert_stream!(
fragment_records,
"if-1",
[for_loop("item", "category.items", "for-2"),]
);
assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
}
#[test]
fn test_complex_directives() {
let mut parser = HtmlParser::new();
let html = r#"<for each="category in categories">
<div class="category">
<h2>{{category.name}}</h2>
<if condition="category.hasItems">
<ul>
<for each="item in category.items">
<li>{{item.title}}</li>
</for>
</ul>
</if>
</div>
</for>"#;
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[for_loop("category", "categories", "for-1"),]
);
assert_stream!(
fragment_records,
"for-1",
[
raw("<div class=\"category\"><h2>"),
signal("category.name"),
raw("</h2>"),
if_cond("if-1"),
raw("</div>"),
]
);
assert_stream!(
fragment_records,
"if-1",
[
raw("<ul>"),
for_loop("item", "category.items", "for-2"),
raw("</ul>"),
]
);
assert_stream!(
fragment_records,
"for-2",
[raw("<li>"), signal("item.title"), raw("</li>"),]
);
}
fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
let mut parser = HtmlParser::new();
let result = parser.parse("index.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
let fragments = records
.get("index.html")
.expect("Failed to get index.html fragment")
.fragments
.clone();
(fragments, records)
}
fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
.expect("register");
let result = parser.parse("index.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
let fragments = records
.get("index.html")
.expect("Failed to get index.html fragment")
.fragments
.clone();
(fragments, records)
}
#[test]
fn test_attribute_handlebars_in_href() {
let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
assert_fragments!(
fragments,
[
raw("<a"),
attr("href", "url"),
raw(">"),
signal("name"),
raw("</a>"),
]
);
}
#[test]
fn test_attribute_boolean_with_handlebars() {
let (fragments, _) =
parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
assert_fragments!(
fragments,
[
raw("<button"),
bool_attr("disabled", "isDisabled"),
raw(">Click</button>"),
]
);
}
#[test]
fn test_attribute_multiple_boolean() {
let (fragments, _) =
parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
assert_fragments!(
fragments,
[
raw("<input"),
bool_attr("checked", "isChecked"),
bool_attr("disabled", "isDisabled"),
raw("/>"),
]
);
}
#[test]
fn test_attribute_boolean_and_regular_together() {
let (fragments, _) = parse_and_get_fragments(
r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
);
assert_fragments!(
fragments,
[
raw("<button"),
bool_attr("disabled", "isDisabled"),
raw(" type=\"button\">Hi</button>"),
]
);
}
#[test]
fn test_attribute_boolean_sandwiched() {
let (fragments, _) = parse_and_get_fragments(
r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
);
assert_fragments!(
fragments,
[
raw("<button"),
attr("version", "edition"),
bool_attr("disabled", "isDisabled"),
raw(" type=\"button\">Hi</button>"),
]
);
}
#[test]
fn test_attribute_boolean_ending() {
let (fragments, _) = parse_and_get_fragments(
r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
);
assert_fragments!(
fragments,
[
raw("<button"),
attr("version", "edition"),
bool_attr("disabled", "isDisabled"),
raw(">Hi</button>"),
]
);
}
#[test]
fn test_attribute_boolean_dotted_path() {
let (fragments, _) =
parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
assert_fragments!(
fragments,
[
raw("<div"),
bool_attr("checked", "layout.isPinned"),
raw(">Content</div>"),
]
);
}
#[test]
fn test_attribute_colon_prefixed_complex() {
let (fragments, _) = parse_with_component(
"my-component",
r#"<my-component :config="{{settings}}"></my-component>"#,
);
assert_fragments!(
fragments,
[
raw("<my-component"),
attr_complex_start(":config", "settings"),
structural_matcher("streaming_root:my-component"),
raw(">"),
component("my-component"),
raw("</my-component>"),
]
);
}
#[test]
fn test_attribute_multiple_colon_prefixed() {
let (fragments, _) = parse_with_component(
"my-component",
r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
);
assert_fragments!(
fragments,
[
raw("<my-component"),
attr_complex_start(":prop1", "val1"),
attr_complex(":prop2", "val2"),
structural_matcher("streaming_root:my-component"),
raw(">"),
component("my-component"),
raw("</my-component>"),
]
);
}
#[test]
fn test_blocked_complex_property_innerhtml() {
let mut parser = HtmlParser::new();
let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
assert!(
result.is_err(),
"Expected error for :innerHTML on native element"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("only allowed on custom elements"),
"Error: {err}"
);
}
#[test]
fn test_blocked_complex_on_native_element() {
let mut parser = HtmlParser::new();
let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
assert!(
result.is_err(),
"Expected error for :data on native element"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("only allowed on custom elements"),
"Error: {err}"
);
}
#[test]
fn test_blocked_complex_property_on_component() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-widget",
"<div></div>",
None,
true,
))
.expect("register");
let result = parser.parse(
"index.html",
r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
);
assert!(
result.is_err(),
"Expected error for :innerHTML on component"
);
let err = result.unwrap_err().to_string();
assert!(err.contains("HTML injection"), "Error: {err}");
}
#[test]
fn test_allowed_complex_property() {
let (fragments, _) = parse_with_component(
"my-component",
r#"<my-component :config="{{settings}}"></my-component>"#,
);
assert_fragments!(
fragments,
[
raw("<my-component"),
attr_complex_start(":config", "settings"),
structural_matcher("streaming_root:my-component"),
raw(">"),
component("my-component"),
raw("</my-component>"),
]
);
}
#[test]
fn test_attribute_mixed_normal_boolean_colon() {
let (fragments, _) = parse_with_component(
"my-component",
r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
);
assert_fragments!(
fragments,
[
raw("<my-component"),
attr_raw_start("id", "comp"),
attr_complex(":config", "settings"),
bool_attr("enabled", "isEnabled"),
structural_matcher("streaming_root:my-component"),
raw(">"),
component("my-component"),
raw("</my-component>"),
]
);
}
#[test]
fn test_attribute_reject_boolean_without_handlebars() {
let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
assert_fragments!(fragments, [raw("<button></button>"),]);
}
#[test]
fn test_attribute_reject_boolean_with_partial_handlebars() {
let (fragments, _) =
parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
assert_fragments!(fragments, [raw("<button></button>"),]);
}
#[test]
fn test_attribute_reject_boolean_with_plain_value() {
let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
assert_fragments!(fragments, [raw("<button>Click</button>"),]);
}
#[test]
fn test_attribute_boolean_predicate_equal() {
let (fragments, _) =
parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
assert_fragments!(
fragments,
[
raw("<div"),
bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
raw(">X</div>"),
]
);
}
#[test]
fn test_attribute_boolean_predicate_greater_than() {
let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
assert_fragments!(
fragments,
[
raw("<span"),
bool_attr_predicate("hidden", "num", 1, "9"),
raw(">X</span>"),
]
);
}
#[test]
fn test_attribute_boolean_predicate_not_equal() {
let (fragments, _) =
parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
assert_fragments!(
fragments,
[
raw("<a"),
bool_attr_predicate("data-active", "status", 4, "'inactive'"),
raw(">X</a>"),
]
);
}
#[test]
fn test_attribute_boolean_negation() {
let (fragments, _) =
parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
assert_fragments!(
fragments,
[
raw("<button"),
bool_attr_not("disabled", "isReady"),
raw(">X</button>"),
]
);
}
#[test]
fn test_attribute_mixed_static_dynamic() {
let (fragments, records) =
parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
assert_fragments!(
fragments,
[
raw("<textarea"),
attr_template("value", "attr-1"),
raw(">Hi</textarea>"),
]
);
assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
}
#[test]
fn test_body_signals() {
let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
assert_fragments!(
fragments,
[
raw("<body>"),
structural_matcher("body_start"),
raw("<app-shell></app-shell>"),
structural_matcher("body_end"),
raw("</body>"),
]
);
}
#[test]
fn test_empty_for_produces_nothing() {
let (fragments, records) =
parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
assert_fragments!(fragments, [raw("<div></div>"),]);
assert!(!records.contains_key("for-1"));
}
#[test]
fn test_self_closing_svg_path() {
let (fragments, _) =
parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
assert_fragments!(
fragments,
[raw(
r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
),]
);
}
#[test]
fn test_html5_void_elements() {
let (fragments, _) = parse_and_get_fragments(
r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
);
assert_fragments!(
fragments,
[raw(
r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
),]
);
}
#[test]
fn test_self_closing_with_dynamic_attributes() {
let (fragments, _) =
parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
assert_fragments!(
fragments,
[
raw("<img"),
attr("src", "imageUrl"),
attr("alt", "imageAlt"),
raw("/>"),
]
);
}
#[test]
fn test_self_closing_with_boolean_attributes() {
let (fragments, _) = parse_and_get_fragments(
r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
);
assert_fragments!(
fragments,
[
raw("<input type=\"checkbox\""),
bool_attr("checked", "isSelected"),
bool_attr("disabled", "isDisabled"),
raw("/>"),
]
);
}
#[test]
fn test_multiple_self_closing_in_sequence() {
let (fragments, _) =
parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
assert_fragments!(
fragments,
[raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
);
}
#[test]
fn test_self_closing_with_mixed_content() {
let (fragments, _) =
parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
assert_fragments!(
fragments,
[
raw("<div>Text before<img"),
attr("src", "url"),
raw("/>Text after</div>"),
]
);
}
#[test]
fn test_self_closing_svg_elements() {
let (fragments, _) = parse_and_get_fragments(
r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
);
assert_fragments!(
fragments,
[
raw("<svg><circle"),
attr("cx", "x"),
attr("cy", "y"),
raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
]
);
}
#[test]
fn test_self_closing_inside_for_loop() {
let (fragments, records) = parse_and_get_fragments(
r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
);
assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
assert_stream!(
records,
"for-1",
[raw("<img"), attr("src", "item.url"), raw("/>"),]
);
}
#[test]
fn test_self_closing_whitespace_variations() {
let (fragments, _) =
parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
assert_fragments!(
fragments,
[raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
);
}
#[test]
fn test_deeply_nested_self_closing() {
let (fragments, _) = parse_and_get_fragments(
r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
);
assert_fragments!(
fragments,
[raw(
r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
),]
);
}
#[test]
fn test_self_closing_vs_empty_regular_tags() {
let (fragments, _) =
parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
assert_fragments!(
fragments,
[raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
);
}
#[test]
fn test_for_custom_id_attribute() {
let (fragments, records) = parse_and_get_fragments(
r#"<for each="item in items" id="static"><span>Item</span></for>"#,
);
assert_fragments!(fragments, [for_loop("item", "items", "static-1"),]);
assert_stream!(records, "static-1", [raw("<span>Item</span>"),]);
}
#[test]
fn test_for_recursive_template() {
let mut parser = HtmlParser::new();
let html = r#"<for id="static" each="item in items"><div><span>{{item.name}}</span><for id="static" each="item in item.children" /></div></for>"#;
let result = parser.parse("index.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[for_loop("item", "items", "static-1"),]
);
assert_stream!(
records,
"static-1",
[
raw("<div><span>"),
signal("item.name"),
raw("</span>"),
for_loop("item", "item.children", "static-1"),
raw("</div>"),
]
);
}
#[test]
fn each_accepts_only_direct_item_in_collection_syntax() {
assert_eq!(
parse_for_each("test-tree", "child in items").unwrap(),
("child", "items")
);
assert_eq!(
parse_for_each("test-tree", " \tchild \n in \r child.children ").unwrap(),
("child", "child.children")
);
}
#[test]
fn braced_each_is_rejected_by_server_and_client_compilation() {
use crate::plugin::webui::generate_compiled_template;
for source in [
r#"<for each="{{child in items}}"><span>{{child.name}}</span></for>"#,
r#"<for id="tree" each="{{child in items}}"><span>{{child.name}}</span></for>"#,
r#"<for id="tree" each="child in items"><for id="tree" each="{{child in child.children}}" /></for>"#,
r#"<for id="tree" each="{{ child in items }}"><span>{{child.name}}</span></for>"#,
r#"<for id="tree" each="{child in items}"><span>{{child.name}}</span></for>"#,
] {
let server = HtmlParser::new().parse("test-tree", source).unwrap_err();
let client = generate_compiled_template("test-tree", source).unwrap_err();
let ParserError::Template(server) = server else {
panic!("expected a server authoring diagnostic");
};
let ParserError::Template(client) = client else {
panic!("expected a client authoring diagnostic");
};
assert_eq!(server.error_code(), client.error_code(), "{source}");
assert!(matches!(
server.error_code(),
Some(codes::INVALID_FOR_EACH | codes::INVALID_FOR_IDENTIFIER)
));
assert!(server.position_line_column().is_some());
assert!(server.help_text().unwrap().contains("without braces"));
assert!(client.help_text().unwrap().contains("without braces"));
}
}
#[test]
fn named_for_recursion_reuses_one_scoped_record() {
let mut parser = HtmlParser::new();
parser
.parse(
"file-a.html",
r#"<for id="tree-item" each="child in items"><li>{{child.name}}</li><for id="tree-item" each="child in child.children" /></for>"#,
)
.unwrap();
let records = parser.into_fragment_records();
assert_eq!(records.len(), 2);
assert_stream!(
records,
"file-a.html",
[for_loop("child", "items", "tree-item-1")]
);
assert_stream!(
records,
"tree-item-1",
[
raw("<li>"),
signal("child.name"),
raw("</li>"),
for_loop("child", "child.children", "tree-item-1"),
]
);
}
#[test]
fn named_for_forward_references_and_empty_definitions() {
let mut parser = HtmlParser::new();
parser
.parse(
"file-a.html",
r#"<for id="loop" each="item in earlier" /><for id="loop" each="item in later"></for>"#,
)
.unwrap();
let records = parser.into_fragment_records();
assert_stream!(
records,
"file-a.html",
[
for_loop("item", "earlier", "loop-1"),
for_loop("item", "later", "loop-1")
]
);
assert!(records["loop-1"].fragments.is_empty());
}
#[test]
fn named_for_scope_survives_component_parsing_and_reparsing() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"other-tree",
r#"<for id="loop" each="item in other"><b>{{item.label}}</b></for>"#,
None,
false,
))
.unwrap();
let source = r#"<for id="loop" each="item in items"><other-tree></other-tree><i>{{item.name}}</i><for id="loop" each="item in item.children" /></for>"#;
parser.parse("file-a.html", source).unwrap();
parser
.parse(
"file-b.html",
r#"<for id="loop" each="item in items">B</for>"#,
)
.unwrap();
parser.parse("file-a.html", source).unwrap();
let records = parser.into_fragment_records();
assert_stream!(
records,
"file-a.html",
[for_loop("item", "items", "loop-1")]
);
assert_stream!(records, "other-tree", [for_loop("item", "other", "loop-2")]);
assert_stream!(
records,
"file-b.html",
[for_loop("item", "items", "loop-3")]
);
assert_stream!(records, "loop-3", [raw("B")]);
assert!(records["loop-1"].fragments.iter().any(|fragment| {
matches!(fragment.fragment.as_ref(), Some(Fragment::ForLoop(repeat)) if repeat.fragment_id == "loop-1")
}));
}
#[test]
fn named_for_reports_authoring_errors() {
for (source, code) in [
(r#"<for id each="x in xs" />"#, codes::INVALID_FOR_ID),
(r#"<for id="" each="x in xs" />"#, codes::INVALID_FOR_ID),
(
r#"<for id="{{name}}" each="x in xs" />"#,
codes::INVALID_FOR_ID,
),
(
r#"<for id="tree" template="tree" each="x in xs" />"#,
codes::INVALID_FOR_ID,
),
(
r#"<for id="tree" id="tree" each="x in xs" />"#,
codes::INVALID_FOR_ID,
),
(r#"<for id="tree" each="x in xs" />"#, codes::UNKNOWN_FOR_ID),
(
r#"<for id="tree" each="x in xs">a</for><for id="tree" each="x in ys">b</for>"#,
codes::DUPLICATE_FOR_ID,
),
(
r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children"><b>{{y.name}}</b></for></for>"#,
codes::DUPLICATE_FOR_ID,
),
(
r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children" /></for>"#,
codes::INCOMPATIBLE_FOR_ITEM,
),
] {
let error = HtmlParser::new().parse("file-a.html", source).unwrap_err();
let ParserError::Template(diagnostic) = error else {
panic!("expected structured diagnostic for {source}");
};
assert_eq!(diagnostic.error_code(), Some(code), "{source}");
assert!(!diagnostic.to_string().contains('\u{1b}'));
assert!(diagnostic.to_string().contains("help:"));
assert!(diagnostic.to_string().contains("file-a.html"));
assert!(diagnostic.position_line_column().is_some(), "{source}");
}
}
#[test]
fn named_for_cannot_reference_a_definition_from_another_file() {
let mut parser = HtmlParser::new();
parser
.parse("a.html", r#"<for id="tree" each="x in xs">a</for>"#)
.unwrap();
let error = parser
.parse("b.html", r#"<for id="tree" each="x in xs" />"#)
.unwrap_err();
assert!(
matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::UNKNOWN_FOR_ID))
);
}
#[test]
fn named_for_does_not_overwrite_generated_fragments() {
let mut parser = HtmlParser::new();
parser
.parse(
"a.html",
r#"<for id="for" each="x in xs">named</for><for each="x in xs">ordinary</for>"#,
)
.unwrap();
let records = parser.into_fragment_records();
assert_stream!(records, "for-1", [raw("named")]);
assert_stream!(records, "for-2", [raw("ordinary")]);
let error = HtmlParser::new()
.parse(
"a.html",
r#"<for each="x in xs">ordinary</for><for id="for" each="x in xs">named</for>"#,
)
.unwrap_err();
assert!(
matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::INVALID_FOR_ID))
);
}
#[test]
fn named_for_css_analysis_terminates_without_losing_sibling_scopes() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
for (name, source, css) in [
("token-leaf", "<b>leaf</b>", "b { color: var(--accent); }"),
(
"defined-tree",
r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
":root { --accent: red; }",
),
(
"undefined-tree",
r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
"",
),
] {
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(name, source, Some(css), false))
.unwrap();
}
parser
.parse(
"index.html",
"<defined-tree></defined-tree><undefined-tree></undefined-tree>",
)
.unwrap();
let analysis = parser.token_analysis();
assert_eq!(analysis.protocol_tokens, ["accent"]);
assert_eq!(analysis.fallback_chains.len(), 1);
}
#[test]
fn named_for_css_analysis_deduplicates_equivalent_diamond_paths() {
use std::fmt::Write;
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"token-leaf",
"<b>leaf</b>",
Some("b { color: var(--accent); }"),
false,
))
.unwrap();
let mut source = String::with_capacity(12 * 150);
for index in 0..12 {
write!(source, r#"<for id="b{index}" each="item in items">"#).unwrap();
if index < 11 {
let next = index + 1;
write!(source, r#"<for id="b{next}" each="item in item.children"/><for id="b{next}" each="item in item.children"/>"#).unwrap();
} else {
source.push_str("<token-leaf></token-leaf>");
}
source.push_str("</for>");
}
parser.parse("index.html", &source).unwrap();
let analysis = parser.token_analysis();
assert_eq!(analysis.protocol_tokens, ["accent"]);
assert_eq!(analysis.fallback_chains.len(), 1);
}
#[test]
fn named_for_css_analysis_preserves_distinct_same_size_definition_sets() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
for (name, source, css) in [
(
"token-leaf",
"<b>leaf</b>",
"b { color: var(--accent); background: var(--shade); }",
),
(
"shared-tree",
r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children"/></for>"#,
"",
),
(
"accent-tree",
"<shared-tree></shared-tree>",
":root { --accent: red; }",
),
(
"shade-tree",
"<shared-tree></shared-tree>",
":root { --shade: blue; }",
),
] {
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(name, source, Some(css), false))
.unwrap();
}
parser
.parse(
"index.html",
"<accent-tree></accent-tree><shade-tree></shade-tree>",
)
.unwrap();
assert_eq!(parser.token_analysis().protocol_tokens, ["accent", "shade"]);
}
#[test]
fn legacy_for_template_attribute_is_rejected() {
for source in [
r#"<for template="legacy" each="x in xs"><span>{{x.name}}</span></for>"#,
r#"<for template="legacy" each="x in xs" />"#,
r#"<for template each="x in xs"></for>"#,
r#"<for template="" each="x in xs"></for>"#,
] {
let error = HtmlParser::new().parse("index.html", source).unwrap_err();
let ParserError::Template(diagnostic) = error else {
panic!("expected an authoring diagnostic for {source}");
};
assert_eq!(diagnostic.error_code(), Some(codes::INVALID_FOR_ID));
assert!(diagnostic
.help_text()
.unwrap()
.contains("replace template with id"));
assert!(diagnostic.position_line_column().is_some());
}
}
#[test]
fn named_for_recursion_still_rejects_reachable_boundaries() {
let error = HtmlParser::new().parse("index.html", r#"<for id="tree" each="x in xs"><for id="tree" each="x in x.children" /><if condition="{{x.name}}"><boundary name="invalid"><b>body</b></boundary></if></for>"#).unwrap_err();
assert!(
matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::BOUNDARY_IN_REPEAT))
);
}
#[test]
fn named_for_fast_components_reject_references_but_keep_plain_loops() {
use crate::plugin::{
fast_v2::FastV2ParserPlugin, fast_v3::FastV3ParserPlugin, ParserPlugin,
ParserPluginArtifacts,
};
for fast_v2 in [true, false] {
for references in [true, false] {
let plugin: Box<dyn ParserPlugin> = if fast_v2 {
Box::new(FastV2ParserPlugin::new())
} else {
Box::new(FastV3ParserPlugin::new())
};
let mut parser = HtmlParser::with_plugin(plugin);
let body = if references {
r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#
} else {
r#"<for id="tree" each="item in items"><if condition="{{item.name}}"><b>{{item.name}}</b></if></for>"#
};
parser
.component_registry_mut()
.register_component(ComponentRegistration::new("my-tree", body, None, true))
.unwrap();
let result = parser.parse("index.html", "<my-tree></my-tree>");
if references {
let error = result.unwrap_err();
assert!(
matches!(error, ParserError::Template(diag) if diag.error_code() == Some("fast-named-for-unsupported") && diag.position_line_column().is_some() && diag.help_text().is_some())
);
} else {
result.unwrap();
let ParserPluginArtifacts::ComponentTemplates(templates) =
parser.take_plugin_artifacts().unwrap()
else {
panic!("FAST produces component templates");
};
assert!(templates[0]
.template
.contains(r#"<f-repeat value="{{item in items}}">"#));
assert!(templates[0]
.template
.contains(r#"<f-when value="{{item.name}}">"#));
}
}
}
}
#[test]
fn test_if_multiple_children() {
let (fragments, records) =
parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
assert_fragments!(fragments, [if_cond("if-1"),]);
assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
}
#[test]
fn if_condition_with_double_quoted_literal_round_trips_through_parser() {
let (fragments, _) =
parse_and_get_fragments(r#"<if condition='status == "ready"'><span>ok</span></if>"#);
let condition = fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(webui_protocol::web_ui_fragment::Fragment::IfCond(if_cond)) => {
if_cond.condition.as_ref()
}
_ => None,
})
.expect("if-condition fragment with a parsed condition");
match condition.expr.as_ref() {
Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
assert_eq!(pred.left, "status");
assert_eq!(pred.right, "\"ready\"");
}
other => panic!("expected a predicate condition, got {other:?}"),
}
}
#[test]
fn test_for_multiple_children() {
let (fragments, records) =
parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
}
#[test]
fn test_handlebars_at_beginning() {
let (fragments, _) = parse_and_get_fragments("{{first}}");
assert_fragments!(fragments, [signal("first"),]);
}
#[test]
fn test_handlebars_at_beginning_and_raw() {
let (fragments, _) = parse_and_get_fragments("{{first}}test");
assert_fragments!(fragments, [signal("first"), raw("test"),]);
}
#[test]
fn test_handlebars_raw_and_end() {
let (fragments, _) = parse_and_get_fragments("test{{first}}");
assert_fragments!(fragments, [raw("test"), signal("first"),]);
}
#[test]
fn test_handlebars_invalid_triple_open() {
let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
assert_fragments!(fragments, [raw("{{{invalid}}"),]);
}
#[test]
fn test_handlebars_four_open_braces() {
let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
}
#[test]
fn test_handlebars_five_open_with_valid_double() {
let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
}
#[test]
fn test_entities_preserved() {
let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
}
#[test]
fn test_doctype_preserved() {
let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
assert!(!fragments.is_empty());
assert!(
matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
);
}
#[test]
fn test_component_attr_skip() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
r#"<template shadowrootmode="open"><slot></slot></template>"#,
None,
true,
))
.expect("register");
let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
let result = parser.parse("index.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[
raw("<custom-element"),
attr_complex_start(":config", "config"),
attr_skip("class", "value0"),
attr_skip("style", "value1"),
attr_skip("role", "value2"),
attr_skip("data-test", "value3"),
attr("aria-test", "value4"),
structural_matcher("streaming_root:custom-element"),
raw(">"),
component("custom-element"),
raw("</custom-element>"),
]
);
}
#[test]
fn test_component_attr_skip_static_and_embedded() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"item-group",
r#"<template shadowrootmode="open"><slot></slot></template>"#,
None,
true,
))
.expect("register");
let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
let result = parser.parse("index.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[
raw("<item-group"),
attr_skip_raw("role", "list"),
FragmentMatcher::Attribute(AttrMatcher {
name: "aria-labelledby".into(),
template: Some("attr-1".into()),
attr_start: true,
..Default::default()
}),
attr_skip_template("data-testid", "attr-2"),
attr_skip_raw("class", "fixed-class"),
structural_matcher("streaming_root:item-group"),
raw(">"),
component("item-group"),
raw("</item-group>"),
]
);
assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
}
#[test]
fn test_component_multiple_nested() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-element",
"<custom-child></custom-child><span></span>",
None,
true,
))
.expect("register");
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-button",
"<span></span>",
None,
true,
))
.expect("register");
parser
.component_registry
.register_component(ComponentRegistration::new(
"custom-child",
"<h1>Hello World!</h1>",
None,
true,
))
.expect("register");
let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
let result = parser.parse("index.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let records = parser.into_fragment_records();
assert_fragments!(
records["index.html"].fragments,
[for_loop("item", "items", "for-1"),]
);
assert_stream!(
records,
"for-1",
[
raw("<custom-element"),
structural_matcher("streaming_root:custom-element"),
raw(">"),
component("custom-element"),
raw("<custom-button"),
structural_matcher("streaming_root:custom-button"),
raw(">"),
component("custom-button"),
raw("Ok</custom-button></custom-element>"),
]
);
let ce = &records["custom-element"].fragments;
assert_eq!(ce.len(), 5);
assert!(
matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
);
assert!(
matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
);
assert!(
matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><span></span>"))
);
assert_stream!(records, "custom-button", [raw("<span></span>"),]);
assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
}
#[test]
fn test_invalid_markup_returns_error() {
let mut parser = HtmlParser::new();
let result = parser.parse("index.html", "<div><span>Unclosed div");
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.to_string().contains("unclosed <div> tag")
));
}
#[test]
fn test_unterminated_opening_tag_returns_error() {
let mut parser = HtmlParser::new();
let result = parser.parse("index.html", "before <div");
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.to_string().contains("malformed HTML tag")
));
}
#[test]
fn test_unterminated_html_comment_returns_error() {
let mut parser = HtmlParser::new();
let result = parser.parse("index.html", "<div><!-- missing close</div>");
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.to_string().contains("unclosed <div> tag")
|| diag.to_string().contains("unterminated HTML comment")
));
}
#[test]
fn test_unexpected_closing_tag_returns_error() {
let mut parser = HtmlParser::new();
let result = parser.parse("index.html", "</span>");
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.to_string().contains("unexpected closing tag </span>")
));
}
#[test]
fn test_rejects_templates_over_size_limit() {
let mut parser = HtmlParser::new();
let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
let result = parser.parse("index.html", &html);
assert!(matches!(result, Err(ParserError::Html(message)) if
message.contains("exceeds") && message.contains("parser limit")
));
}
#[test]
fn test_rejects_templates_over_depth_limit() {
let mut parser = HtmlParser::new();
let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
for _ in 0..=MAX_TEMPLATE_DEPTH {
html.push_str("<div>");
}
for _ in 0..=MAX_TEMPLATE_DEPTH {
html.push_str("</div>");
}
let result = parser.parse("index.html", &html);
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.to_string().contains("nesting exceeds")
));
}
#[test]
fn test_rejects_routes_over_depth_limit() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"route-page",
r#"<template shadowrootmode="open"><slot></slot></template>"#,
None,
true,
))
.expect("register");
let depth = MAX_TEMPLATE_DEPTH + 2;
let mut html = String::with_capacity(depth * 48);
for _ in 0..depth {
html.push_str(r#"<route path="x" component="route-page">"#);
}
for _ in 0..depth {
html.push_str("</route>");
}
let result = parser.parse("index.html", &html);
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.error_code() == Some(codes::EXCESSIVE_NESTING)
&& diag.to_string().contains("route nesting exceeds")
));
}
#[test]
fn test_rejects_recursive_component_templates() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"self-card",
"<self-card></self-card>",
None,
true,
))
.expect("register");
let result = parser.parse("index.html", "<self-card></self-card>");
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
&& diag.to_string().contains("recursive template reference")
));
}
#[test]
fn test_complex_raw_text_full_page() {
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>"#;
let (fragments, _) = parse_and_get_fragments(html);
assert!(fragments.len() >= 9);
assert!(
matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
);
assert!(
matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
);
assert!(
matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("<title>Test</title>"))
);
assert!(
matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
);
assert!(
matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("</head>") && raw.value.ends_with("<body>"))
);
assert!(
matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
);
assert!(
matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("<app-shell>"))
);
assert!(
matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
);
assert!(
matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("</body>") && raw.value.contains("</html>"))
);
}
#[test]
fn raw_bindings_in_html_text_contexts_do_not_own_html_ranges() {
for element in ["title", "textarea", "script", "xmp"] {
let html = format!("<{element}>{{{{{{value}}}}}}</{element}>");
let (fragments, _) = parse_and_get_fragments(&html);
let signal = fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
_ => None,
});
let signal = signal
.unwrap_or_else(|| panic!("expected raw signal inside <{element}>: {fragments:?}"));
assert!(
signal.raw,
"<{element}> should preserve raw brace semantics"
);
assert!(
signal.raw_text_context,
"<{element}> should suppress HTML ownership markers"
);
}
}
#[test]
fn raw_bindings_in_template_content_own_html_ranges() {
let (fragments, _) = parse_and_get_fragments("<template>{{{value}}}</template>");
let signal = fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
_ => None,
});
let signal =
signal.unwrap_or_else(|| panic!("expected raw signal inside template: {fragments:?}"));
assert!(signal.raw);
assert!(!signal.raw_text_context);
}
#[test]
fn test_css_strategy_external_defers_link_to_server_closure() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
r#"<template shadowrootmode="open"><p>content</p></template>"#,
Some("p { color: red; }"),
true,
))
.ok();
parser.parse("index.html", "<my-card>Hello</my-card>").ok();
let records = parser.into_fragment_records();
let my_card = &records["my-card"].fragments;
let raw_text: String = my_card
.iter()
.filter_map(|f| match &f.fragment {
Some(Fragment::Raw(r)) => Some(r.value.as_str()),
_ => None,
})
.collect();
assert!(
!raw_text.contains(r#"<link rel="stylesheet""#),
"component templates must not own tree-local links: {}",
raw_text
);
assert!(my_card.iter().any(|fragment| matches!(
fragment.fragment.as_ref(),
Some(Fragment::Signal(signal))
if signal.value == "}}}webui:shadow_styles:my-card"
)));
}
#[test]
fn test_uppercase_style_element_is_processed_as_css() {
let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
assert!(
fragments.iter().any(|f| matches!(
f.fragment.as_ref(),
Some(Fragment::Signal(s))
if s.value == "tokens" && !s.raw && s.raw_text_context
)),
"uppercase <STYLE> should be CSS-processed, got: {:?}",
fragments
);
let raw: String = fragments
.iter()
.filter_map(|f| match f.fragment.as_ref() {
Some(Fragment::Raw(r)) => Some(r.value.as_str()),
_ => None,
})
.collect();
assert!(
raw.contains("</STYLE>"),
"closing tag should preserve source case, got: {raw}"
);
}
#[test]
fn test_css_strategy_inline_defers_style_to_server_closure() {
let mut parser = HtmlParser::with_options(CssStrategy::Style);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<p>content</p>",
Some("p { color: red; }"),
true,
))
.expect("register component");
parser
.parse("index.html", "<my-card>Hello</my-card>")
.expect("parse component");
let records = parser.into_fragment_records();
let my_card = &records["my-card"].fragments;
let raw_text: String = my_card
.iter()
.filter_map(|f| match &f.fragment {
Some(Fragment::Raw(r)) => Some(r.value.as_str()),
_ => None,
})
.collect();
assert!(
!raw_text.contains("<style>"),
"component templates must not own tree-local styles: {}",
raw_text
);
assert!(
!raw_text.contains("<link"),
"Should not have <link> tag in inline mode: {}",
raw_text
);
}
#[test]
fn test_css_strategy_module_emits_adopted_stylesheets() {
let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<p>content</p>",
Some("p { color: red; }"),
true,
))
.expect("register component");
parser
.parse("index.html", "<my-card>Hello</my-card>")
.expect("parse component");
let records = parser.into_fragment_records();
let my_card = &records["my-card"].fragments;
let template_text: String = my_card
.iter()
.filter_map(|f| match &f.fragment {
Some(Fragment::Raw(r)) => Some(r.value.as_str()),
_ => None,
})
.collect();
assert!(
!template_text.contains("shadowrootadoptedstylesheets"),
"shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
);
assert!(
!template_text.contains("<link"),
"Should not have <link> in module mode: {template_text}"
);
assert!(
!template_text.contains(r#"<style type="module""#),
"CSS module should NOT be in raw fragments (legacy shape): {template_text}"
);
assert!(
!template_text.contains(r#"<script type="importmap""#),
"CSS module importmap should NOT be in raw fragments: {template_text}"
);
}
#[test]
fn light_component_css_remains_authored_and_unscoped() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<p class=\"label\">content</p>",
Some(".label{color:red}"),
false,
))
.expect("register component");
parser
.parse("index.html", "<my-card></my-card>")
.expect("parse component");
let css = parser
.component_registry()
.get("my-card")
.and_then(|component| component.css_content.as_deref())
.expect("authored CSS");
assert_eq!(css, ".label{color:red}");
}
#[test]
fn light_component_inline_styles_remain_unscoped_and_unmarked() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
let built = parser
.build_component_templates(ComponentTemplateInput {
tag_name: "my-card",
html: "<style>.label{color:red}</style><p class=\"label\">content</p>",
artifact_html: None,
authored_html: None,
css_content: None,
artifact_needed: false,
})
.expect("build Light component");
assert_eq!(
built.ssr,
"<style>.label{color:red}</style><p class=\"label\">content</p>"
);
assert!(!built.ssr.contains("data-wl"));
}
#[test]
fn light_component_rejects_shadow_only_selectors() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
for selector in [":host", ":host-context(body.dark)", "::slotted(*)"] {
let html = format!("<style>{selector}{{color:red}}</style><p>content</p>");
let result = parser.build_component_templates(ComponentTemplateInput {
tag_name: "my-card",
html: &html,
artifact_html: None,
authored_html: None,
css_content: None,
artifact_needed: false,
});
let error = match result {
Ok(_) => panic!("Shadow-only selector must fail in Light DOM"),
Err(error) => error,
};
assert!(matches!(
error,
ParserError::Template(ref diagnostic)
if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
));
}
}
#[test]
fn light_component_external_css_rejects_host_selector() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<p>content</p>",
Some(":host{display:block}"),
false,
))
.expect("register component");
let error = parser
.parse("index.html", "<my-card></my-card>")
.expect_err("external :host must fail in Light DOM");
assert!(matches!(
error,
ParserError::Template(ref diagnostic)
if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
));
}
#[test]
fn shadow_component_inline_styles_remain_tree_local_and_unchanged() {
let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
let built = parser
.build_component_templates(ComponentTemplateInput {
tag_name: "my-card",
html: "<style>:host{display:block}.label{color:red}</style><p class=\"label\">content</p>",
artifact_html: None,
authored_html: None,
css_content: None,
artifact_needed: false,
})
.expect("build Shadow component");
assert!(built.ssr.contains(
"<template shadowrootmode=\"open\"><style>:host{display:block}.label{color:red}</style>"
));
assert!(!built.ssr.contains("data-wl"));
}
#[test]
fn nested_light_hosts_are_unmarked() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-child",
"<p class=\"inner\">child</p>",
Some(".inner{color:green}"),
false,
))
.expect("register child");
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<my-child></my-child>",
Some("my-child{display:block}"),
false,
))
.expect("register parent");
parser
.parse("index.html", "<my-card></my-card>")
.expect("parse component");
let records = parser.into_fragment_records();
let raw: String = records["my-card"]
.fragments
.iter()
.filter_map(|fragment| match &fragment.fragment {
Some(web_ui_fragment::Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect();
assert!(raw.contains("<my-child>"), "nested host was changed: {raw}");
assert!(!raw.contains("data-wl"), "Light markers remain: {raw}");
}
#[test]
fn raw_html_binding_does_not_change_global_css() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div class=\"body\">{{{descriptionHtml}}}</div>",
Some(".body p{margin:0}"),
false,
))
.expect("register component");
parser
.parse("index.html", "<my-card></my-card>")
.expect("parse component");
let css = parser
.component_registry()
.get("my-card")
.and_then(|component| component.css_content.as_deref())
.expect("authored CSS");
assert_eq!(css, ".body p{margin:0}");
}
#[test]
fn test_css_strategy_module_no_css_no_adopted_attr() {
let mut parser = HtmlParser::with_options(CssStrategy::Module);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
None,
true,
))
.ok();
parser.parse("index.html", "<my-card>Hello</my-card>").ok();
let records = parser.into_fragment_records();
let my_card = &records["my-card"].fragments;
let template_text: String = my_card
.iter()
.filter_map(|f| match &f.fragment {
Some(Fragment::Raw(r)) => Some(r.value.as_str()),
_ => None,
})
.collect();
assert!(
!template_text.contains("shadowrootadoptedstylesheets"),
"Should not have adopted attr without CSS: {template_text}"
);
}
#[test]
fn light_preserves_dev_template_with_static_attrs() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
let processed = parser
.process_component_template(
r#"<template foo="bar"><div>hi</div></template>"#,
None,
None,
)
.expect("process failed");
assert!(
processed.contains(r#"<template foo="bar">"#),
"expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
);
assert!(
processed.contains("</template>"),
"expected closing </template> preserved, got: {processed}"
);
}
#[test]
fn light_preserves_dev_template_with_signal_fragment_attrs() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
let processed = parser
.process_component_template(
r#"<template foo="{{foo}}"><div>hi</div></template>"#,
None,
None,
)
.expect("process failed");
assert!(
processed.contains(r#"<template foo="{{foo}}">"#),
"expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
);
}
#[test]
fn light_preserves_dev_template_with_multiple_attrs() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
let processed = parser.process_component_template(
r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
None,
None,
)
.expect("process failed");
assert!(
processed.contains("autofocus")
&& processed.contains(r#"tabindex="0""#)
&& processed.contains(r#"role="region""#)
&& processed.contains(r#"data-x="y""#),
"expected all dev template attrs preserved, got: {processed}"
);
}
#[test]
fn light_no_template_no_wrapper_added() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
let processed = parser
.process_component_template("<div>hi</div>", None, None)
.expect("process failed");
assert!(
!processed.contains("<template"),
"framework must not add a <template> wrapper when dev omits one, got: {processed}"
);
assert!(
processed.contains("<div>hi</div>"),
"expected inner content emitted as-is, got: {processed}"
);
}
#[test]
fn ordinary_template_remains_light_dom_content() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
let processed = parser
.process_component_template(
r#"<template foo="bar"><div>hi</div></template>"#,
None,
None,
)
.expect("process failed");
assert!(
processed.contains(r#"<template foo="bar">"#),
"ordinary template content must be preserved, got: {processed}"
);
assert!(
!processed.contains("shadowrootmode"),
"ordinary template must not gain a generated Shadow root, got: {processed}"
);
}
#[test]
fn bare_template_is_unwrapped_as_explicit_light_dom() {
let mut parser = HtmlParser::new();
let built = parser
.build_component_templates(ComponentTemplateInput {
tag_name: "my-card",
html: "<template><div class=\"label\">hi</div></template>",
artifact_html: None,
authored_html: None,
css_content: Some(".label{color:red}"),
artifact_needed: false,
})
.expect("bare template should select Light DOM");
assert!(!built.uses_shadow_dom);
assert_eq!(built.ssr, r#"<div class="label">hi</div>"#);
}
#[test]
fn attributed_template_keeps_the_shadow_fallback() {
let mut parser = HtmlParser::new();
let built = parser
.build_component_templates(ComponentTemplateInput {
tag_name: "my-card",
html: r#"<template data-purpose="content"><div>hi</div></template>"#,
artifact_html: None,
authored_html: None,
css_content: None,
artifact_needed: false,
})
.expect("attributed template should remain ordinary content");
assert!(built.uses_shadow_dom);
assert_eq!(
built.ssr,
r#"<template shadowrootmode="open"><template data-purpose="content"><div>hi</div></template></template>"#
);
}
#[test]
fn closed_shadow_wrapper_is_rejected_during_template_processing() {
let mut parser = HtmlParser::new();
let error = parser
.process_component_template(
r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
None,
None,
)
.expect_err("closed roots must fail");
assert!(matches!(
error,
ParserError::Template(ref diagnostic)
if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
));
}
#[test]
fn ordinary_template_preserves_signal_fragment_attrs() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
let processed = parser
.process_component_template(
r#"<template foo="{{foo}}"><div>hi</div></template>"#,
None,
None,
)
.expect("process failed");
assert!(
processed.starts_with(r#"<template foo="{{foo}}">"#),
"ordinary template must remain Light content, got: {processed}"
);
}
#[test]
fn unwrapped_component_never_gains_shadow_wrapper() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
let processed = parser
.process_component_template("<div>hi</div>", None, None)
.expect("process failed");
assert!(
!processed.contains("shadowrootmode"),
"framework must not generate a Shadow root, got: {processed}"
);
assert!(
processed.contains("<div>hi</div>"),
"Light content must survive processing, got: {processed}"
);
}
#[test]
fn default_unwrapped_component_gains_open_shadow_wrapper() {
let mut parser = HtmlParser::new();
let processed = parser
.process_component_template("<div>hi</div>", None, None)
.expect("process failed");
assert_eq!(
processed,
r#"<template shadowrootmode="open"><div>hi</div></template>"#
);
}
#[test]
fn leading_comments_do_not_change_root_template_detection() {
const COMMENT: &str = "<!-- Copyright (C) Corporation. All rights reserved. -->";
let fixtures = [
(
format!(
"{COMMENT}\n<template shadowrootmode=\"open\">\n <h1>Hello</h1>\n</template>"
),
"<template shadowrootmode=\"open\">\n <h1>Hello</h1>\n</template>",
),
(format!("{COMMENT}\n<h1>Hello</h1>"), "<h1>Hello</h1>"),
(format!("{COMMENT}\nHello"), "Hello"),
];
for (input, expected) in fixtures {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
let processed = parser
.process_component_template(&input, None, None)
.expect("leading-comment fixture should process");
assert_eq!(processed, expected);
}
}
#[test]
fn component_policy_wrapper_is_build_only_and_unwrapped_for_light() {
let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
let mut shadow = HtmlParser::with_options(DomStrategy::Shadow);
let shadow_built = shadow
.build_component_templates(ComponentTemplateInput {
tag_name: "my-comp",
html,
artifact_html: None,
authored_html: None,
css_content: None,
artifact_needed: true,
})
.expect("shadow policy wrapper should compile");
assert_eq!(
shadow_built.ssr,
r#"<template shadowrootmode="open"><div>hi</div></template>"#
);
assert!(!shadow_built.artifact().contains("w-hydrate"));
let mut light = HtmlParser::with_options(DomStrategy::Light);
let light_built = light
.build_component_templates(ComponentTemplateInput {
tag_name: "my-comp",
html,
artifact_html: None,
authored_html: None,
css_content: None,
artifact_needed: true,
})
.expect("light policy wrapper should compile");
assert_eq!(light_built.ssr, "<div>hi</div>");
assert_eq!(light_built.artifact(), "<div>hi</div>");
let mut implicit_shadow = HtmlParser::new();
let shadow_built = implicit_shadow
.build_component_templates(ComponentTemplateInput {
tag_name: "my-comp",
html,
artifact_html: None,
authored_html: None,
css_content: None,
artifact_needed: true,
})
.expect("default policy wrapper should compile as Shadow");
assert_eq!(
shadow_built.ssr,
r#"<template shadowrootmode="open"><div>hi</div></template>"#
);
assert!(!shadow_built.artifact().contains("w-hydrate"));
let shadow_html =
r#"<template shadowrootmode="open" w-hydrate="lazy"><div>hi</div></template>"#;
let mut shadow = HtmlParser::new();
let authored_shadow_built = shadow
.build_component_templates(ComponentTemplateInput {
tag_name: "my-comp",
html: shadow_html,
artifact_html: None,
authored_html: None,
css_content: None,
artifact_needed: true,
})
.expect("shadow policy wrapper should compile");
assert_eq!(
authored_shadow_built.ssr,
r#"<template shadowrootmode="open"><div>hi</div></template>"#
);
assert!(!authored_shadow_built.artifact().contains("w-hydrate"));
}
#[test]
fn interaction_policy_marks_component_hosts_in_ssr_fragments() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"interaction-shell",
r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
None,
true,
))
.expect("component registration");
parser
.parse(
"index.html",
"<body><interaction-shell></interaction-shell></body>",
)
.expect("entry parse");
let records = parser.into_fragment_records();
let entry_html: String = records["index.html"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect();
assert!(entry_html.contains("<interaction-shell data-webui-interaction"));
}
#[test]
fn combined_interaction_policy_marks_hosts_and_emits_render_css() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"interaction-panel",
concat!(
r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
r#"w-hydrate="interaction"><button>Open</button></template>"#,
),
None,
true,
))
.expect("component registration");
parser
.parse(
"index.html",
"<body><interaction-panel></interaction-panel></body>",
)
.expect("entry parse");
assert!(parser
.component_registry
.render_policy_css(["interaction-panel"])
.contains("content-visibility:auto"));
let records = parser.into_fragment_records();
let entry_html: String = records["index.html"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect();
assert!(entry_html.contains("<interaction-panel data-webui-interaction"));
}
#[test]
fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
let mut parser = HtmlParser::new();
let processed = parser
.process_component_template(
r#"<template shadowrootmode="open"><div>hi</div></template>"#,
None,
Some("my-comp"),
)
.expect("process failed");
assert!(
processed.contains(r#"shadowrootmode="open""#),
"dev-authored shadowrootmode must be preserved, got: {processed}"
);
assert!(
processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
"module CSS should append adopted stylesheets, got: {processed}"
);
}
#[test]
fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
let mut parser = HtmlParser::new();
let processed = parser
.process_component_template(
r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
None,
Some("my-comp"),
)
.unwrap();
assert!(
processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
"dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
);
assert_eq!(
processed.matches("shadowrootadoptedstylesheets").count(),
1,
"framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
);
}
#[test]
fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
let built = match parser.build_component_templates(ComponentTemplateInput {
tag_name: "my-comp",
html: r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
artifact_html: None,
authored_html: None,
css_content: Some(":host { color: red; }"),
artifact_needed: true,
}) {
Ok(built) => built,
Err(err) => panic!(
"dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
),
};
let artifact = built.artifact();
assert!(artifact.contains(r#"shadowrootmode="open""#));
assert!(artifact.contains(r#"@click="{onClick()}""#));
assert!(artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#));
assert_eq!(artifact.matches("shadowrootadoptedstylesheets").count(), 1);
assert!(!built.ssr.contains("@click"));
}
}
#[test]
fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
let mut parser = HtmlParser::new();
let processed = parser
.process_component_template(
r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
None,
Some("my-comp"),
)
.unwrap();
assert!(
processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
"dev's multi-specifier list must be preserved verbatim, got: {processed}"
);
}
#[test]
fn dev_template_no_adopted_specifier_does_not_validate() {
let mut parser = HtmlParser::new();
let processed = parser
.process_component_template(
r#"<template shadowrootmode="open"><div>hi</div></template>"#,
None,
None,
)
.unwrap();
assert!(
!processed.contains("shadowrootadoptedstylesheets"),
"framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
);
}
#[test]
fn test_body_start_end_injection() {
let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
let (fragments, _) = parse_and_get_fragments(html);
assert_fragments!(
fragments,
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<title>Test</title>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
raw("<div>Content</div><p>More</p>"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_body_preserves_static_attributes() {
let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
let (fragments, _) = parse_and_get_fragments(html);
assert_fragments!(
fragments,
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<title>T</title>"),
structural_matcher("head_end"),
raw(r#"</head><body data-layout="doc" class="page">"#),
structural_matcher("body_start"),
raw("<p>x</p>"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_body_preserves_dynamic_attributes() {
let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
let (fragments, _) = parse_and_get_fragments(html);
assert_fragments!(
fragments,
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<title>T</title>"),
structural_matcher("head_end"),
raw("</head><body"),
attr("data-layout", "layout"),
raw(">"),
structural_matcher("body_start"),
raw("<p>x</p>"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_head_start_signal_ordering_with_authored_script() {
let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
let (fragments, _) = parse_and_get_fragments(html);
assert_fragments!(
fragments,
[
raw("<html><head>"),
structural_matcher("head_start"),
raw(r#"<script async src="./analytics.js"></script>"#),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_head_static_attributes_preserve_legacy_opening_bytes() {
let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
let (fragments, _) = parse_and_get_fragments(html);
assert_fragments!(
fragments,
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<title>T</title>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
let html =
r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
let (fragments, _) = parse_and_get_fragments(html);
assert_fragments!(
fragments,
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<title>T</title>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn native_head_and_body_are_ascii_case_insensitive() {
let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
let (fragments, _) = parse_and_get_fragments(html);
assert_fragments!(
fragments,
[
raw(r#"<html><HEAD data-theme="ignored">"#),
structural_matcher("head_start"),
raw("<title>T</title>"),
structural_matcher("head_end"),
raw("</HEAD><BODY"),
attr("data-layout", "layout"),
raw(">"),
structural_matcher("body_start"),
raw("<p>x</p>"),
structural_matcher("body_end"),
raw("</BODY></html>"),
]
);
}
#[test]
fn entry_boundary_is_an_inline_start_end_tape() {
let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
let (fragments, records) = parse_and_get_fragments(html);
let boundary = match fragments[3].fragment.as_ref() {
Some(Fragment::Boundary(boundary)) => boundary,
other => panic!("expected typed boundary start, got {other:?}"),
};
assert_eq!(boundary.phase(), BoundaryPhase::Start);
assert_eq!(boundary.declaration_id, 0);
assert_eq!(boundary.owner_fragment_id, "index.html");
assert_eq!(boundary.name, "counter-ready");
assert_eq!(boundary.key, None);
assert!(!boundary.may_repeat);
assert_stream!(
records,
"index.html",
[
raw("<body>"),
structural_matcher("body_start"),
raw("<div>"),
boundary_matcher("counter-ready", 0),
raw("<my-counter></my-counter>"),
boundary_end_matcher(0),
raw("</div>"),
structural_matcher("body_end"),
raw("</body>"),
]
);
assert!(records["index.html"].contains_boundary);
assert!(
!records.contains_key("boundary-1"),
"an inline tape must not create a body record"
);
}
#[test]
fn boundary_names_are_scoped_per_owner_and_declaration_ids_are_protocol_wide() {
let mut parser = HtmlParser::new();
parser
.parse(
"first.html",
r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
)
.expect("first entry parses");
parser
.parse(
"second.html",
r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
)
.expect("a second owner may reuse the name");
let records = parser.into_fragment_records();
let first = records["first.html"]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) => Some(boundary),
_ => None,
})
.expect("first boundary");
let second = records["second.html"]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) => Some(boundary),
_ => None,
})
.expect("second boundary");
assert_eq!((first.declaration_id, first.name.as_str()), (0, "a"));
assert_eq!((second.declaration_id, second.name.as_str()), (1, "a"));
}
#[test]
fn boundary_missing_name_errors() {
for html in [
"<body><boundary><p>x</p></boundary></body>",
r#"<body><boundary name=""><p>x</p></boundary></body>"#,
r#"<body><boundary name=" "><p>x</p></boundary></body>"#,
] {
let mut parser = HtmlParser::new();
let err = parser
.parse("index.html", html)
.expect_err("a boundary without a non-empty name must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
}
}
#[test]
fn boundary_dynamic_name_errors() {
let mut parser = HtmlParser::new();
let err = parser
.parse(
"index.html",
r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
)
.expect_err("a dynamic boundary name must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
}
#[test]
fn boundary_names_are_free_form_and_live_on_typed_fragments() {
let mut parser = HtmlParser::new();
let html = concat!(
"<body>",
r#"<boundary name="above the fold"><p>1</p></boundary>"#,
r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
"</body>",
);
parser
.parse("index.html", html)
.expect("free-form boundary names must be accepted");
let records = parser.into_fragment_records();
let names: Vec<&str> = records["index.html"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) if boundary.phase() == BoundaryPhase::Start => {
Some(boundary.name.as_str())
}
_ => None,
})
.collect();
assert_eq!(names, ["above the fold", "feed/items #2", "ダッシュボード"]);
}
#[test]
fn module_entry_srcs_records_only_preloadable_critical_entries() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"weather-island",
r#"<script type="module" src="/nested-weather.js"></script>"#,
None,
true,
))
.expect("register");
let html = concat!(
"<head>",
r#"<script type="module" async src="/index.js"></script>"#,
r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
r#"<script src="/legacy.js"></script>"#,
r#"<script type="module">import "./x.js";</script>"#,
r#"<script type="module" src="/{{bundle}}.js"></script>"#,
"</head>",
"<body>",
r#"<boundary name="weather">"#,
r#"<script type="module" async src="/weather-panel.js"></script>"#,
"<weather-island></weather-island>",
"</boundary>",
r#"<script type="module" src="/index.js"></script>"#,
"</body>",
);
parser.parse("index.html", html).expect("parse");
assert_eq!(
parser.module_entry_srcs(),
["/index.js", "/late.js"],
"only static module entries outside boundaries are preloadable"
);
}
#[test]
fn module_entry_srcs_reset_between_top_level_parses() {
let mut parser = HtmlParser::new();
parser
.parse(
"a.html",
r#"<head><script type="module" src="/a.js"></script></head>"#,
)
.expect("parse a");
assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
parser
.parse(
"b.html",
r#"<head><script type="module" src="/b.js"></script></head>"#,
)
.expect("parse b");
assert_eq!(
parser.module_entry_srcs(),
["/b.js"],
"each entry owns its own critical modules"
);
}
#[test]
fn module_entry_srcs_preserve_low_priority_script_delivery() {
let mut parser = HtmlParser::new();
let html = concat!(
"<head>",
r#"<script type="module" async src="/streaming.js"></script>"#,
r#"<script type="module" src="/low-head.js" fetchpriority="LOW"></script>"#,
"</head><body>",
r#"<script type="module" src="/application.js" fetchpriority="low"></script>"#,
r#"<script type="module" src="/low-cased.js" FETCHPRIORITY="LoW"></script>"#,
r#"<script type="module" src="/critical.js" fetchpriority="high"></script>"#,
r#"<script type="module" src="/ordinary.js"></script>"#,
"</body>",
);
parser.parse("index.html", html).expect("parse");
assert_eq!(
parser.module_entry_srcs(),
["/streaming.js", "/critical.js", "/ordinary.js"],
"low-priority application chunks must not be pulled into head preloads"
);
let records = parser.into_fragment_records();
let raw = records["index.html"]
.fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
_ => None,
})
.collect::<String>();
assert!(
raw.contains(r#"src="/application.js" fetchpriority="low""#),
"excluding preloads must preserve the authored script"
);
}
#[test]
fn boundary_duplicate_name_errors() {
let mut parser = HtmlParser::new();
let html = concat!(
"<body>",
r#"<boundary name="dup"><p>1</p></boundary>"#,
r#"<boundary name="dup"><p>2</p></boundary>"#,
"</body>",
);
let err = parser
.parse("index.html", html)
.expect_err("a duplicate boundary name must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
}
#[test]
fn boundary_nested_boundary_errors() {
let mut parser = HtmlParser::new();
let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
let err = parser
.parse("index.html", html)
.expect_err("a nested boundary must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
}
#[test]
fn boundary_transitively_nested_through_component_errors() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"inner-panel",
r#"<boundary name="inner"><p>x</p></boundary>"#,
None,
true,
))
.expect("register");
let err = parser
.parse(
"index.html",
r#"<body><boundary name="outer"><inner-panel></inner-panel></boundary></body>"#,
)
.expect_err("transitively nested boundaries must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
assert!(diag
.help_text()
.is_some_and(|help| help.contains("runtime branches")));
}
#[test]
fn boundary_inside_if_preserves_false_branch_structure() {
let mut parser = HtmlParser::new();
let html =
r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
parser.parse("index.html", html).expect("parse");
let records = parser.into_fragment_records();
let if_fragment_id = records["index.html"]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::IfCond(if_cond)) => Some(if_cond.fragment_id.as_str()),
_ => None,
})
.expect("if reference");
let boundary = records[if_fragment_id]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) => Some(boundary),
_ => None,
})
.expect("boundary remains behind the runtime if");
assert_eq!(boundary.owner_fragment_id, "index.html");
assert!(!boundary.may_repeat);
assert!(records["index.html"].contains_boundary);
assert!(records[if_fragment_id].contains_boundary);
assert_stream!(
records,
if_fragment_id,
[
boundary_matcher("x", 0),
raw("<p>x</p>"),
boundary_end_matcher(0),
]
);
}
#[test]
fn boundary_directly_inside_for_is_rejected() {
let mut parser = HtmlParser::new();
let err = parser
.parse(
"index.html",
r#"<body><for each="item in items"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></for></body>"#,
)
.expect_err("a boundary inside a repeat must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
assert!(
diag.help_text()
.is_some_and(|help| help.contains("wrap the whole <for>")),
"{diag}"
);
}
#[test]
fn boundary_under_if_inside_for_is_rejected() {
let mut parser = HtmlParser::new();
let err = parser
.parse(
"index.html",
r#"<body><for each="item in items"><if condition="item.ready"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></if></for></body>"#,
)
.expect_err("a boundary behind a runtime branch in a repeat must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
}
#[test]
fn component_boundary_used_inside_for_is_rejected() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"row-item",
r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
None,
true,
))
.expect("register");
let err = parser
.parse(
"index.html",
r#"<body><for each="item in items"><row-item></row-item></for></body>"#,
)
.expect_err("a component-local boundary reached from a repeat must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
assert!(diag.to_string().contains("row-ready"), "{diag}");
assert_eq!(diag.component_name(), Some("index.html"));
assert!(diag.to_string().contains("item in items"), "{diag}");
}
#[test]
fn transitive_repeat_boundary_diagnostic_uses_lowest_repeat_site() {
for _ in 0..20 {
let mut parser = HtmlParser::new();
for (tag, template) in [
(
"left-list",
r#"<for each="a in xs"><row-card></row-card></for>"#,
),
(
"right-list",
r#"<for each="b in ys"><row-card></row-card></for>"#,
),
(
"row-card",
r#"<boundary name="row-ready"><p>row</p></boundary>"#,
),
] {
parser
.component_registry
.register_component(ComponentRegistration::new(tag, template, None, true))
.expect("register");
}
let err = parser
.parse(
"index.html",
r#"<body><left-list></left-list><right-list></right-list></body>"#,
)
.expect_err("both repeats reach the same boundary declaration");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
assert_eq!(diag.component_name(), Some("left-list"));
assert!(diag.to_string().contains("a in xs"), "{diag}");
}
}
#[test]
fn route_boundary_reached_from_for_is_rejected() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(
"row-page",
r#"<boundary name="row-ready"><p>row</p></boundary>"#,
None,
true,
))
.expect("register");
let err = parser
.parse(
"index.html",
r#"<body><for each="item in items"><route path="/" component="row-page"></route></for></body>"#,
)
.expect_err("a route-mounted boundary inside a repeat must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
}
#[test]
fn outlet_mounted_boundary_reached_from_for_is_rejected() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
for (tag, template) in [
(
"shell-page",
r#"<div><for each="item in items"><outlet></outlet></for></div>"#,
),
(
"child-page",
r#"<boundary name="child-ready"><p>child</p></boundary>"#,
),
] {
parser
.component_registry
.register_component(ComponentRegistration::new(tag, template, None, true))
.expect("register");
}
let err = parser
.parse(
"index.html",
r#"<body><route path="/" component="shell-page"><route path="child" component="child-page"></route></route></body>"#,
)
.expect_err("an outlet-mounted boundary inside a repeat must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
assert!(diag.to_string().contains("child-ready"), "{diag}");
}
#[test]
fn for_inside_one_boundary_is_allowed_and_stays_atomic() {
let mut parser = HtmlParser::new();
parser
.parse(
"index.html",
r#"<body><boundary name="feed"><for each="item in items"><p>{{item.label}}</p></for></boundary></body>"#,
)
.expect("a repeat inside one boundary parses");
let records = parser.into_fragment_records();
assert!(records["index.html"].contains_boundary);
assert!(
!records["for-1"].contains_boundary,
"the repeat body itself declares no boundary"
);
let boundary = records["index.html"]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) => Some(boundary),
_ => None,
})
.expect("boundary");
assert!(!boundary.may_repeat);
}
#[test]
fn boundary_after_for_is_allowed() {
let mut parser = HtmlParser::new();
parser
.parse(
"index.html",
r#"<body><for each="item in items"><p>{{item.label}}</p></for><boundary name="tail"><p>tail</p></boundary></body>"#,
)
.expect("a boundary following a repeat parses");
let records = parser.into_fragment_records();
assert!(records["index.html"].contains_boundary);
assert!(!records["for-1"].contains_boundary);
}
#[test]
fn boundary_free_for_still_renders_without_boundary_metadata() {
let mut parser = HtmlParser::new();
parser
.parse(
"index.html",
r#"<body><for each="item in items"><p>{{item.label}}</p></for></body>"#,
)
.expect("an ordinary repeat parses");
let records = parser.into_fragment_records();
assert!(!records["index.html"].contains_boundary);
assert!(!records["for-1"].contains_boundary);
}
#[test]
fn empty_boundary_key_is_still_rejected() {
let mut parser = HtmlParser::new();
let err = parser
.parse(
"invalid.html",
r#"<body><boundary name="x" key=""><p>x</p></boundary></body>"#,
)
.expect_err("an empty key must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_KEY));
}
#[test]
fn boundary_inside_reusable_component_is_component_local() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-widget",
r#"<boundary name="x"><p>x</p></boundary>"#,
None,
true,
))
.expect("register");
parser
.parse(
"index.html",
r#"<body><my-widget></my-widget><boundary name="x"><p>entry</p></boundary></body>"#,
)
.expect("component-local boundary parses");
let records = parser.into_fragment_records();
let boundary = records["my-widget"]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) => Some(boundary),
_ => None,
})
.expect("component boundary");
assert_eq!(boundary.owner_fragment_id, "my-widget");
assert_eq!(boundary.name, "x");
assert!(records["my-widget"].contains_boundary);
assert!(records["index.html"].contains_boundary);
let owners: HashSet<&str> = records
.values()
.flat_map(|list| list.fragments.iter())
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) if boundary.name == "x" => {
Some(boundary.owner_fragment_id.as_str())
}
_ => None,
})
.collect();
assert_eq!(owners, HashSet::from(["index.html", "my-widget"]));
}
#[test]
fn component_boundary_repeated_from_static_callsites_requires_a_key() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"row-item",
r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
None,
true,
))
.expect("register");
let err = parser
.parse(
"index.html",
r#"<body><row-item></row-item><row-item></row-item></body>"#,
)
.expect_err("a repeated declaration without a key must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_KEY));
assert_eq!(diag.component_name(), Some("row-item"));
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"row-item",
r#"<boundary name="row-ready" key="{{rowId}}"><span>{{label}}</span></boundary>"#,
None,
true,
))
.expect("register");
parser
.parse(
"index.html",
r#"<body><row-item row-id="a"></row-item><row-item row-id="b"></row-item></body>"#,
)
.expect("a keyed repeated declaration parses");
let records = parser.into_fragment_records();
let boundary = records["row-item"]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) => Some(boundary),
_ => None,
})
.expect("component boundary");
assert!(boundary.may_repeat);
assert_eq!(boundary.key.as_deref(), Some("{{rowId}}"));
}
#[test]
fn component_boundary_once_per_independent_entry_is_not_repeatable() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"shared-panel",
r#"<boundary name="ready"><span>x</span></boundary>"#,
None,
true,
))
.expect("register");
for entry in ["first.html", "second.html"] {
parser
.parse(entry, "<body><shared-panel></shared-panel></body>")
.expect("entry parses");
}
let records = parser.into_fragment_records();
let boundary = records["shared-panel"]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) => Some(boundary),
_ => None,
})
.expect("component boundary");
assert!(!boundary.may_repeat);
}
#[test]
fn boundary_inside_component_host_content_errors() {
let mut parser = HtmlParser::new();
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-widget",
"<div>content</div>",
None,
true,
))
.expect("register");
let err = parser
.parse(
"index.html",
r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
)
.expect_err("a boundary inside component host content must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
assert!(
diag.to_string().contains("component host content"),
"{diag}"
);
}
#[test]
fn boundary_inside_raw_or_inert_html_context_errors() {
for (element, expected_scope) in [
("textarea", "<textarea> text content"),
("title", "<title> text content"),
("script", "<script> raw-text content"),
("xmp", "<xmp> raw-text content"),
("iframe", "<iframe> raw-text content"),
("noembed", "<noembed> raw-text content"),
("noframes", "<noframes> raw-text content"),
("noscript", "<noscript> inert content"),
("plaintext", "<plaintext> text content"),
("template", "<template> inert content"),
] {
let mut parser = HtmlParser::new();
let html = format!(
r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
);
let err = parser
.parse("index.html", &html)
.expect_err("a boundary in raw or inert content must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(
diag.error_code(),
Some(codes::BOUNDARY_CROSSES_SCOPE),
"unexpected code for <{element}>"
);
assert!(
diag.to_string().contains(expected_scope),
"unexpected scope for <{element}>: {diag}"
);
}
}
#[test]
fn route_contained_boundary_has_runtime_content_fragment() {
let mut parser = HtmlParser::with_options(DomStrategy::Light);
parser
.component_registry
.register_component(ComponentRegistration::new(
"home-page",
"<h1>Home</h1>",
None,
true,
))
.expect("register");
parser
.parse(
"index.html",
r#"<route path="/" component="home-page"><boundary name="route-ready"><aside>Ready</aside></boundary></route>"#,
)
.expect("route-contained boundary parses");
let records = parser.into_fragment_records();
assert!(records["index.html"].contains_boundary);
let route = records["index.html"]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Route(route)) => Some(route),
_ => None,
})
.expect("route");
assert!(!route.content_fragment_id.is_empty());
let boundary = records[&route.content_fragment_id]
.fragments
.iter()
.find_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Boundary(boundary)) => Some(boundary),
_ => None,
})
.expect("route boundary");
assert_eq!(boundary.owner_fragment_id, "index.html");
assert_eq!(boundary.name, "route-ready");
assert_stream!(
records,
route.content_fragment_id.as_str(),
[
boundary_matcher("route-ready", 0),
raw("<aside>Ready</aside>"),
boundary_end_matcher(0),
]
);
}
#[test]
fn route_boundary_nested_in_ignored_markup_errors() {
let mut parser = HtmlParser::new();
let err = parser
.parse(
"index.html",
r#"<route path="/" component="home-page"><div><boundary name="late"><p>x</p></boundary></div></route>"#,
)
.expect_err("route boundaries must be direct children");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(
diag.error_code(),
Some(codes::INVALID_ROUTE_BOUNDARY_PLACEMENT)
);
}
#[test]
fn boundary_must_be_inside_an_open_body() {
for html in [
r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
] {
let mut parser = HtmlParser::new();
let err = parser
.parse("index.html", html)
.expect_err("a boundary outside body must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
assert_eq!(
diag.help_text(),
Some("move the <boundary> between the opening <body> and its matching </body>")
);
}
let mut parser = HtmlParser::new();
parser
.parse(
"index.html",
r#"<BODY><boundary name="inside"></boundary></BODY>"#,
)
.expect("mixed-case native body keeps the boundary inside an open body");
}
#[test]
fn boundary_inside_foster_parenting_context_errors() {
for html in [
r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
] {
let mut parser = HtmlParser::new();
let err = parser
.parse("index.html", html)
.expect_err("a boundary in a foster-parenting context must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(
diag.error_code(),
Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
"unexpected code for {html}"
);
assert!(
diag.help_text()
.is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
"help should suggest a safe placement for {html}"
);
}
}
#[test]
fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
for html in [
r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
concat!(
"<body><table><tbody><tr><td>",
"<table><tbody><tr><td>inner</td></tr></tbody></table>",
r#"<boundary name="after-inner"><p>x</p></boundary>"#,
"</td></tr></tbody></table></body>",
),
r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
] {
let mut parser = HtmlParser::new();
parser
.parse("index.html", html)
.unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
}
}
#[test]
fn foster_context_depth_is_restored_after_leaving_a_table() {
let mut parser = HtmlParser::new();
parser
.parse(
"index.html",
concat!(
"<body>",
"<table><tbody><tr><td>x</td></tr></tbody></table>",
r#"<boundary name="after-table"><p>y</p></boundary>"#,
"</body>",
),
)
.expect("a boundary after a closed table must be accepted");
}
#[test]
fn authored_raw_bindings_cannot_forge_structural_signals() {
let html = concat!(
"<html><head></head><body>",
"{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
"{{{boundary_start:0}}}{{{boundary_end:0}}}",
"{{{streaming_root:forged}}}",
"</body></html>",
);
let (fragments, _) = parse_and_get_fragments(html);
let raw_signals: Vec<&str> = fragments
.iter()
.filter_map(|fragment| match fragment.fragment.as_ref() {
Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
_ => None,
})
.collect();
assert_eq!(
raw_signals,
[
"}}}webui:head_start",
"}}}webui:head_end",
"}}}webui:body_start",
"head_start",
"head_end",
"body_start",
"body_end",
"boundary_start:0",
"boundary_end:0",
"streaming_root:forged",
"}}}webui:body_end",
]
);
}
#[test]
fn authored_webui_hydrate_errors_at_top_level() {
for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
let mut parser = HtmlParser::new();
let html = format!("<div><{tag}></{tag}></div>");
let err = parser
.parse("index.html", &html)
.expect_err("an authored <webui-hydrate> must error in any ASCII casing");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
}
}
#[test]
fn authored_webui_hydrate_errors_inside_route() {
for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
let mut parser = HtmlParser::new();
let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
let err = parser
.parse("index.html", &html)
.expect_err("an authored <webui-hydrate> inside <route> must error");
let ParserError::Template(diag) = err else {
panic!("expected ParserError::Template, got {err:?}");
};
assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
}
}
#[test]
fn boundary_directive_remains_case_sensitive() {
let (fragments, _) =
parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
assert_fragments!(
fragments,
[raw(
r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
),]
);
}
#[test]
fn templates_without_webui_boundary_are_unaffected() {
let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
let (fragments, _) = parse_and_get_fragments(html);
assert_fragments!(
fragments,
[
raw("<div><my-counter"),
attr("count", "count"),
raw("></my-counter></div>"),
]
);
}
#[test]
fn test_fail_with_invalid_markup() {
let mut parser = HtmlParser::new();
let result = parser.parse("index.html", "<div><span></div></span>");
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.to_string().contains("unclosed <span> tag")
));
}
#[test]
fn test_complex_raw_text_page() {
let html = concat!(
"<!DOCTYPE html>",
"<html lang=\"en\">",
"<head>",
"<meta charset=\"utf-8\">",
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
"<title>Complex Page</title>",
"<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
"<link rel=\"stylesheet\" href=\"styles.css\">",
"</head>",
"<body>",
"<h1>Hello World</h1>",
"<script type=\"module\" src=\"./app.js\"></script>",
"</body>",
"</html>",
);
let (fragments, _) = parse_and_get_fragments(html);
assert!(
fragments.len() >= 9,
"Expected at least 9 fragments, got {}",
fragments.len()
);
assert!(
matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
"First fragment should contain DOCTYPE and end with <head>, got: {:?}",
fragments[0]
);
assert!(
matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
"Second fragment should be head_start signal"
);
assert!(
matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("<meta charset=\"utf-8\">") &&
raw.value.contains("<meta name=\"viewport\"") &&
raw.value.contains("<title>Complex Page</title>") &&
raw.value.contains("<style>") &&
raw.value.contains("body { margin: 0; padding: 0; }")),
"Third fragment should contain all head content, got: {:?}",
fragments[2]
);
assert!(
matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
"Fourth fragment should be head_end signal"
);
assert!(
matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("</head>") && raw.value.ends_with("<body>")),
"Fifth fragment should contain </head><body>"
);
assert!(
matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
"Sixth fragment should be body_start signal"
);
assert!(
matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("<h1>Hello World</h1>") &&
raw.value.contains("<script")),
"Seventh fragment should contain body content"
);
assert!(
matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
"Eighth fragment should be body_end signal"
);
assert!(
matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
raw.value.contains("</body>") && raw.value.contains("</html>")),
"Ninth fragment should contain closing tags"
);
}
struct BindingCountPlugin {
counts: Vec<u32>,
}
impl BindingCountPlugin {
fn new() -> Self {
Self { counts: Vec::new() }
}
}
impl crate::plugin::ParserPlugin for BindingCountPlugin {
fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
let attr_name = context.name;
if attr_name.starts_with('@') || attr_name == "f-ref" {
AttributeAction::SkipAndCountBinding
} else {
AttributeAction::Keep
}
}
fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
let binding_attribute_count = context.binding_count;
self.counts.push(binding_attribute_count);
if binding_attribute_count > 0 {
Some(binding_attribute_count.to_le_bytes().to_vec())
} else {
None
}
}
}
struct TemplateCapturePlugin {
template: Option<String>,
uses_shadow_dom: bool,
}
impl TemplateCapturePlugin {
fn new() -> Self {
Self {
template: None,
uses_shadow_dom: false,
}
}
}
impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
self.template = Some(context.template.to_string());
self.uses_shadow_dom = context.uses_shadow_dom;
Ok(())
}
fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
let attr_name = context.name;
if attr_name.starts_with('@') || attr_name == "f-ref" {
AttributeAction::SkipAndCountBinding
} else {
AttributeAction::Keep
}
}
fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
match self.template {
Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
crate::plugin::ComponentTemplateArtifact::template(
"todo-app".to_string(),
template,
self.uses_shadow_dom,
),
])),
None => Ok(ParserPluginArtifacts::None),
}
}
}
#[test]
fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
let mut parser = HtmlParser::with_plugin_options(
Box::new(TemplateCapturePlugin::new()),
CssStrategy::Link,
);
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"todo-app",
r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
Some(":host { display: block; }"),
true,))
.expect("register todo-app");
parser
.parse("index.html", "<todo-app></todo-app>")
.expect("parse failed");
let artifacts = parser.take_plugin_artifacts().expect("artifacts");
let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
panic!("expected captured component template");
};
let template = &templates[0].template;
assert!(
template.contains(r#"shadowrootmode="open""#),
"parser must pass dev-authored root template attrs to plugins, got: {template}"
);
assert!(
template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
"parser must pass dev-authored root event attrs to plugins, got: {template}"
);
assert!(
template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
"parser must pass dev-authored root event attrs to plugins, got: {template}"
);
assert!(
template.contains(r#"f-ref="{root}""#),
"parser must pass dev-authored root FAST attrs to plugins, got: {template}"
);
assert!(
!template.contains("shadowrootadoptedstylesheets"),
"link CSS must not add CSS module adoption attrs, got: {template}"
);
}
#[test]
fn test_component_static_attrs_not_counted_as_bindings() {
let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-btn",
r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
None,
true,
))
.expect("register");
let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
let result = parser.parse("index.html", html);
assert!(result.is_ok());
let records = parser.into_fragment_records();
let frags = &records["index.html"].fragments;
let plugin_count = frags
.iter()
.filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
.count();
assert_eq!(
plugin_count, 0,
"Static-only component attributes should not emit a Plugin fragment"
);
}
#[test]
fn test_component_dynamic_attr_counted_as_binding() {
let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-btn",
r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
None,
true,
))
.expect("register");
let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
let result = parser.parse("index.html", html);
assert!(result.is_ok());
let records = parser.into_fragment_records();
let frags = &records["index.html"].fragments;
let plugin_frags: Vec<_> = frags
.iter()
.filter_map(|f| match f.fragment.as_ref() {
Some(Fragment::Plugin(p)) => Some(&p.data),
_ => None,
})
.collect();
assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
let count = u32::from_le_bytes([
plugin_frags[0][0],
plugin_frags[0][1],
plugin_frags[0][2],
plugin_frags[0][3],
]);
assert_eq!(count, 1, "Binding attribute count should be 1");
}
#[test]
fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-btn",
r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
None,
true,
))
.expect("register");
let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
let result = parser.parse("index.html", html);
assert!(result.is_ok());
let records = parser.into_fragment_records();
let frags = &records["index.html"].fragments;
let plugin_frags: Vec<_> = frags
.iter()
.filter_map(|f| match f.fragment.as_ref() {
Some(Fragment::Plugin(p)) => Some(&p.data),
_ => None,
})
.collect();
assert_eq!(plugin_frags.len(), 1);
let count = u32::from_le_bytes([
plugin_frags[0][0],
plugin_frags[0][1],
plugin_frags[0][2],
plugin_frags[0][3],
]);
assert_eq!(
count, 2,
"Binding count should include dynamic + plugin-skipped attrs, not static"
);
}
#[test]
fn test_component_only_skipped_plugin_attrs_counted() {
let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
parser
.component_registry
.register_component(ComponentRegistration::new(
"my-btn",
r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
None,
true,
))
.expect("register");
let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
let result = parser.parse("index.html", html);
assert!(result.is_ok());
let records = parser.into_fragment_records();
let frags = &records["index.html"].fragments;
let plugin_frags: Vec<_> = frags
.iter()
.filter_map(|f| match f.fragment.as_ref() {
Some(Fragment::Plugin(p)) => Some(&p.data),
_ => None,
})
.collect();
assert_eq!(plugin_frags.len(), 1);
let count = u32::from_le_bytes([
plugin_frags[0][0],
plugin_frags[0][1],
plugin_frags[0][2],
plugin_frags[0][3],
]);
assert_eq!(
count, 2,
"Only plugin-skipped attrs should be counted, not static title"
);
}
#[test]
fn test_comment_handlebars_signal_is_stripped() {
let mut parser = HtmlParser::new();
let html = "<!--{{tokens}}-->";
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
assert_stream!(records, "test.html", []);
}
#[test]
fn test_comment_triple_brace_raw_signal_is_stripped() {
let mut parser = HtmlParser::new();
let html = "<!--{{{tokens}}}-->";
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
assert_stream!(records, "test.html", []);
}
#[test]
fn test_comment_regular_stripped() {
let mut parser = HtmlParser::new();
let html = "<!-- regular comment -->";
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
assert_stream!(records, "test.html", []);
}
#[test]
fn test_comment_dotted_signal_is_stripped() {
let mut parser = HtmlParser::new();
let html = "<!--{{tokens.light}}-->";
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
assert_stream!(records, "test.html", []);
}
#[test]
fn test_comment_arbitrary_identifier_is_stripped() {
let mut parser = HtmlParser::new();
let html = "<!--{{someOtherBinding}}-->";
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
assert_stream!(records, "test.html", []);
}
#[test]
fn test_comment_with_surrounding_content_is_stripped() {
let mut parser = HtmlParser::new();
let html = "<div><!--{{tokens}}--></div>";
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
assert_stream!(records, "test.html", [raw("<div></div>")]);
}
#[test]
fn test_component_template_unterminated_comment_returns_error() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"x-bad",
"<span>ok</span><!-- missing close",
None,
true,
))
.expect("register failed");
let result = parser.parse("index.html", "<x-bad></x-bad>");
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.to_string().contains("unterminated HTML comment")
));
}
#[test]
fn test_webui_plugin_strips_component_comments_before_metadata() {
let mut parser =
HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"x-bleed",
r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
None,
true,
))
.expect("register failed");
parser
.parse("index.html", "<x-bleed></x-bleed>")
.expect("parse failed");
let artifacts = parser.take_plugin_artifacts().unwrap();
let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
panic!("expected component templates");
};
let template = &templates[0].template_json;
assert!(template.contains("<div>hello</div>"));
assert!(!template.contains("{{path}}"));
assert!(!template.contains("@click"));
assert!(!template.contains("?hidden"));
assert!(!template.contains("-->"));
}
#[test]
fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
let mut parser =
HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"x-style",
"<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
None,
true,
))
.expect("register failed");
parser
.parse("index.html", "<x-style></x-style>")
.expect("parse failed");
let artifacts = parser.take_plugin_artifacts().unwrap();
let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
panic!("expected component templates");
};
let template = &templates[0].template_json;
assert!(template.contains(".x { color: red; }"));
assert!(!template.contains("ignored"));
assert!(!template.contains("//"));
}
#[test]
fn test_tokens_from_style_tag() {
let mut parser = HtmlParser::new();
let html = r#"<style>
.btn { color: var(--colorPrimary); background: var(--bgColor); }
</style>"#;
parser.parse("test.html", html).expect("parse failed");
let tokens = parser.take_tokens();
assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
}
#[test]
fn test_tokens_from_malformed_style_error_on_unclosed_var() {
let mut parser = HtmlParser::new();
let html = r#"<style>
.bad { color: var(--dangling; }
.ok { color: var(--valid); }
</style>"#;
let result = parser.parse("test.html", html);
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.error_code() == Some(codes::INVALID_CSS)
&& diag.to_string().contains("Unterminated CSS var() call")
));
}
#[test]
fn test_tokens_from_component_css() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-button",
"<button>Click</button>",
Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
true,
))
.expect("register failed");
let html = "<my-button></my-button>";
parser.parse("test.html", html).expect("parse failed");
let tokens = parser.take_tokens();
assert_eq!(tokens, vec!["borderWidth", "textColor"]);
}
#[test]
fn test_token_requirements_preserve_fallback_chains() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
true,
))
.expect("register failed");
parser
.parse("test.html", "<my-card></my-card>")
.expect("parse failed");
let analysis = parser.token_analysis();
assert_eq!(analysis.fallback_chains.len(), 1);
assert_eq!(
analysis.fallback_chains[0].tokens,
vec!["token-a", "token-b", "token-c"]
);
assert!(!analysis.fallback_chains[0].has_literal_fallback);
}
#[test]
fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
Some(
":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
),
true,))
.expect("register failed");
parser
.parse("test.html", "<my-card></my-card>")
.expect("parse failed");
let analysis = parser.token_analysis();
assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
assert_eq!(analysis.fallback_chains.len(), 1);
assert_eq!(
analysis.fallback_chains[0].tokens,
vec!["token-b", "token-c"]
);
}
#[test]
fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
Some(
":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
),
true,))
.expect("register failed");
parser
.parse("test.html", "<my-card></my-card>")
.expect("parse failed");
let analysis = parser.token_analysis();
let theme = webui_tokens::TokenFile {
themes: HashMap::from([(
"light".to_string(),
HashMap::from([("token-b".to_string(), "green".to_string())]),
)]),
};
let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
panic!("missing --token-c in the theme must fail parser validation");
};
assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
let help = diagnostic.help_text().expect("help text");
assert!(help.contains("did you mean --token-b?"), "help: {help}");
assert!(help.contains("define it locally"), "help: {help}");
assert!(!help.contains("add --token"), "help: {help}");
}
#[test]
fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
Some(":host { color: var(--brand, #000); }"),
true,
))
.expect("register failed");
parser
.parse("test.html", "<my-card></my-card>")
.expect("parse failed");
let analysis = parser.token_analysis();
assert_eq!(analysis.protocol_tokens, vec!["brand"]);
let theme = webui_tokens::TokenFile {
themes: HashMap::from([("light".to_string(), HashMap::new())]),
};
analysis
.validate_theme_tokens(&theme)
.expect("literal fallback must exempt --brand from theme validation");
}
#[test]
fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
Some(":host { color: var(--brand, #000); background: var(--brand); }"),
true,
))
.expect("register failed");
parser
.parse("test.html", "<my-card></my-card>")
.expect("parse failed");
let analysis = parser.token_analysis();
let theme = webui_tokens::TokenFile {
themes: HashMap::from([("light".to_string(), HashMap::new())]),
};
let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
panic!("a bare var(--brand) usage must require --brand in the theme");
};
assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
assert!(diagnostic.to_string().contains("--brand"));
}
#[test]
fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
Some(
":host { \
color: var(--colr-brand, #000); \
border: var(--present, 1px); \
margin: var(--required); \
}",
),
true,
))
.expect("register failed");
parser
.parse("test.html", "<my-card></my-card>")
.expect("parse failed");
let analysis = parser.token_analysis();
let theme = webui_tokens::TokenFile {
themes: HashMap::from([(
"light".to_string(),
HashMap::from([
("present".to_string(), "2px".to_string()),
("required".to_string(), "8px".to_string()),
]),
)]),
};
assert_eq!(
analysis.unthemed_literal_fallback_tokens(&theme),
vec!["colr-brand".to_string()]
);
}
#[test]
fn test_theme_token_error_reports_location_and_suggestion() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
":host {\n color: var(--color-neutral-2000);\n}".into(),
true,
))
.expect("register failed");
parser
.parse("test.html", "<my-card></my-card>")
.expect("parse failed");
let analysis = parser.token_analysis();
let theme = webui_tokens::TokenFile {
themes: HashMap::from([(
"dark".to_string(),
HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
)]),
};
let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
panic!("missing --color-neutral-2000 must fail validation");
};
assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
let location = diag.location().expect("a source location");
assert!(
location.contains("my-card.css:2:14"),
"location: {location}"
);
assert!(
diag.snippet_text()
.is_some_and(|s| s.contains("--color-neutral-2000")),
"snippet: {:?}",
diag.snippet_text()
);
assert!(
diag.help_text()
.is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
"help: {:?}",
diag.help_text()
);
}
#[test]
fn test_theme_token_warning_reports_location_and_suggestion() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
":host {\n color: var(--colr-brand, #000);\n}".into(),
true,
))
.expect("register failed");
parser
.parse("test.html", "<my-card></my-card>")
.expect("parse failed");
let analysis = parser.token_analysis();
let theme = webui_tokens::TokenFile {
themes: HashMap::from([(
"light".to_string(),
HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
)]),
};
let warnings = analysis.theme_token_warnings(&theme);
assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
let body = warnings[0].body();
assert!(body.contains("my-card.css:2:"), "warning: {body}");
assert!(body.contains("--colr-brand"), "warning: {body}");
assert!(
body.contains("did you mean --color-brand?"),
"warning: {body}"
);
}
#[test]
fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
let mut parser = HtmlParser::new();
let result =
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
true,
));
assert!(matches!(result, Err(ParserError::Css(message)) if
message.contains("Unterminated CSS var() call")
));
}
#[test]
fn test_tokens_merged_from_style_and_components() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-widget",
"<div>Widget</div>",
Some(".w { padding: var(--spacingM); }"),
true,
))
.expect("register failed");
let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
parser.parse("test.html", html).expect("parse failed");
let tokens = parser.take_tokens();
assert_eq!(tokens, vec!["spacingM", "textColor"]);
}
#[test]
fn test_tokens_deduplicated() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-btn",
"<button>B</button>",
Some(".b { color: var(--shared); }"),
true,
))
.expect("register failed");
let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
parser.parse("test.html", html).expect("parse failed");
let tokens = parser.take_tokens();
assert_eq!(tokens, vec!["shared"]);
}
#[test]
fn test_tokens_empty_when_no_vars() {
let mut parser = HtmlParser::new();
let html = "<div>Hello</div>";
parser.parse("test.html", html).expect("parse failed");
let tokens = parser.take_tokens();
assert!(tokens.is_empty());
}
#[test]
fn test_tokens_exclude_locally_defined_in_component() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
Some(":host { --local: 5px; width: var(--external); }"),
true,
))
.expect("register failed");
let html = "<my-card></my-card>";
parser.parse("test.html", html).expect("parse failed");
let tokens = parser.take_tokens();
assert_eq!(tokens, vec!["external"]);
}
#[test]
fn test_tokens_exclude_entry_root_definitions() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-btn",
"<button>B</button>",
Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
true,
))
.expect("register failed");
let html = r#"<style>
:root {
--color-primary: #0078d4;
--radius-m: 6px;
}
body { color: var(--color-primary); }
</style>
<my-btn></my-btn>"#;
parser.parse("test.html", html).expect("parse failed");
let tokens = parser.take_tokens();
assert!(
tokens.is_empty(),
"Tokens defined in entry :root should be excluded: {tokens:?}"
);
}
#[test]
fn test_tokens_entry_defs_exclude_but_external_kept() {
let mut parser = HtmlParser::new();
parser
.component_registry_mut()
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
true,
))
.expect("register failed");
let html = r#"<style>
:root { --color-primary: #0078d4; }
</style>
<my-card></my-card>"#;
parser.parse("test.html", html).expect("parse failed");
let tokens = parser.take_tokens();
assert_eq!(tokens, vec!["external-spacing"]);
}
#[test]
fn test_parse_simple_route() {
let mut parser = HtmlParser::new();
let html = r#"<route path="/profile" component="profile-page" exact />"#;
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
let frags = &records["test.html"].fragments;
assert_eq!(frags.len(), 1);
match frags[0].fragment.as_ref() {
Some(web_ui_fragment::Fragment::Route(r)) => {
assert_eq!(r.path, "/profile");
assert_eq!(r.fragment_id, "profile-page");
assert!(r.exact);
}
other => panic!("expected Fragment::Route, got {:?}", other),
}
}
#[test]
fn test_parse_route_with_params() {
let mut parser = HtmlParser::new();
let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
let frags = &records["test.html"].fragments;
match frags[0].fragment.as_ref() {
Some(web_ui_fragment::Fragment::Route(r)) => {
assert_eq!(r.path, "/profile/:id/view/:section");
}
other => panic!("expected Fragment::Route, got {:?}", other),
}
}
#[test]
fn test_parse_route_requires_component() {
let mut parser = HtmlParser::new();
let html = r#"<route path="/old" />"#;
let result = parser.parse("test.html", html);
assert!(result.is_err());
}
#[test]
fn test_parse_multiple_routes() {
let mut parser = HtmlParser::new();
let html = r#"<route path="/" component="app-layout" />
<route path="/dashboard" component="dash-page" exact />
<route path="/contacts" component="contacts-page" exact />"#;
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
let frags = &records["test.html"].fragments;
assert_eq!(frags.len(), 3);
for frag in frags {
assert!(matches!(
frag.fragment.as_ref(),
Some(web_ui_fragment::Fragment::Route(_))
));
}
}
#[test]
fn test_parse_route_requires_component_with_body() {
let mut parser = HtmlParser::new();
let html = r#"<route path="/error">
<div class="error"><h1>Not Found</h1></div>
</route>"#;
let result = parser.parse("test.html", html);
assert!(
result.is_err(),
"Route without component attribute should fail"
);
}
#[test]
fn test_parse_multiple_routes_with_fragments() {
let mut parser = HtmlParser::new();
let html = r#"<route path="/" component="home-page" exact />
<route path="/about" component="about-page" exact />
<route path="/contact/:id" component="contact-page" />"#;
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
let frags = &records["test.html"].fragments;
assert_eq!(frags.len(), 3);
if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
assert_eq!(r.path, "/");
}
if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
assert_eq!(r.path, "/about");
}
if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
assert_eq!(r.path, "/contact/:id");
}
}
#[test]
fn test_parse_route_with_query_allowlist() {
let mut parser = HtmlParser::new();
let html =
r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
let frags = &records["test.html"].fragments;
assert_eq!(frags.len(), 1);
match frags[0].fragment.as_ref() {
Some(web_ui_fragment::Fragment::Route(r)) => {
assert_eq!(r.path, "/compose");
assert_eq!(r.fragment_id, "compose-page");
assert!(r.exact);
assert_eq!(r.allowed_query, "action,to,subject");
}
other => panic!("expected Fragment::Route, got {:?}", other),
}
}
#[test]
fn test_parse_route_without_query_has_empty_allowed_query() {
let mut parser = HtmlParser::new();
let html = r#"<route path="/profile" component="profile-page" exact />"#;
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
let frags = &records["test.html"].fragments;
match frags[0].fragment.as_ref() {
Some(web_ui_fragment::Fragment::Route(r)) => {
assert!(r.allowed_query.is_empty());
}
other => panic!("expected Fragment::Route, got {:?}", other),
}
}
#[test]
fn test_route_body_rejects_malformed_non_route_html() {
let mut parser = HtmlParser::new();
let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
let result = parser.parse("test.html", html);
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.to_string().contains("unclosed <span> tag")
));
}
#[test]
fn test_route_body_rejects_malformed_style_css() {
let mut parser = HtmlParser::new();
let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
let result = parser.parse("test.html", html);
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.error_code() == Some(codes::INVALID_CSS)
&& diag.to_string().contains("Unterminated CSS var() call")
));
}
#[test]
fn test_outlet_not_captured_by_for_loop() {
let mut parser = HtmlParser::new();
let html = r#"<template shadowrootmode="open">
<ul>
<for each="item in items">
<li>{{item.name}}</li>
</for>
</ul>
<main>
<outlet />
</main>
</template>"#;
parser.parse("comp.html", html).expect("parse failed");
let records = parser.into_fragment_records();
let frags = &records["comp.html"].fragments;
let outlet_count = frags
.iter()
.filter(|f| {
matches!(
f.fragment.as_ref(),
Some(web_ui_fragment::Fragment::Outlet(_))
)
})
.count();
assert_eq!(
outlet_count, 1,
"expected exactly 1 outlet in top-level fragments, got {outlet_count}"
);
let outlet_idx = frags
.iter()
.position(|f| {
matches!(
f.fragment.as_ref(),
Some(web_ui_fragment::Fragment::Outlet(_))
)
})
.expect("no outlet found");
let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
_ => false,
});
if let Some(ul_idx) = close_ul_idx {
assert!(
outlet_idx > ul_idx,
"outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
);
}
}
#[test]
fn test_outlet_position_after_for_not_inside() {
let mut parser = HtmlParser::new();
let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
parser.parse("test.html", html).expect("parse failed");
let records = parser.into_fragment_records();
let frags = &records["test.html"].fragments;
let has_outlet = frags.iter().any(|f| {
matches!(
f.fragment.as_ref(),
Some(web_ui_fragment::Fragment::Outlet(_))
)
});
assert!(
has_outlet,
"outlet should be in top-level fragments: {frags:?}"
);
let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
_ => None,
});
if let Some(id) = for_id {
let for_frags = &records[&id].fragments;
let outlet_in_for = for_frags.iter().any(|f| {
matches!(
f.fragment.as_ref(),
Some(web_ui_fragment::Fragment::Outlet(_))
)
});
assert!(
!outlet_in_for,
"outlet should NOT be inside for-loop body: {for_frags:?}"
);
}
}
#[test]
fn multiple_outlets_in_one_fragment_warn() {
let mut parser = HtmlParser::new();
let html = r#"<main><outlet /></main><aside><outlet /></aside>"#;
parser.parse("test.html", html).expect("parse failed");
let warnings = parser.take_warnings();
assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
assert_eq!(warnings[0].severity(), Severity::Warning);
assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
assert_eq!(warnings[0].component_name(), Some("test.html"));
assert!(warnings[0].help_text().is_some());
}
#[test]
fn multiple_outlets_across_nested_directives_warn() {
for html in [
r#"<if condition="show"><outlet /></if><outlet />"#,
r#"<for each="item in items"><outlet /></for><outlet />"#,
] {
let mut parser = HtmlParser::new();
parser.parse("test.html", html).expect("parse failed");
let warnings = parser.take_warnings();
assert_eq!(warnings.len(), 1, "warnings for {html}: {warnings:?}");
assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
}
}
#[test]
fn test_style_element_with_handlebars_signal_comment_emits_signal() {
let mut parser = HtmlParser::new();
let html = r#"<html><head><style>
:root {
/*{{{tokens.light}}}*/
}
</style></head><body></body></html>"#;
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style>\n:root {\n "),
raw_text_signal("tokens.light", true),
raw("\n}\n</style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_style_comment_signal_with_spaces_emits_signal() {
let mut parser = HtmlParser::new();
let html = r#"<html><head><style>
:root {
/* {{{tokens.light}}} */
}
</style></head><body></body></html>"#;
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style>\n:root {\n "),
raw_text_signal("tokens.light", true),
raw("\n}\n</style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_style_comment_signal_double_brace_emits_signal() {
let mut parser = HtmlParser::new();
let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style>"),
raw_text_signal("themeCss", false),
raw("</style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_style_comment_with_extra_text_is_stripped() {
let mut parser = HtmlParser::new();
let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style></style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_style_comment_with_multiple_signals_is_stripped() {
let mut parser = HtmlParser::new();
let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style></style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_style_bare_handlebars_stays_raw() {
let mut parser = HtmlParser::new();
let html =
"<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style>body { color: {{textColor}}; }</style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_style_mixed_css_and_comment_signal_emits_signal() {
let mut parser = HtmlParser::new();
let html = r#"<html><head><style>
.a { color: red; }
/*{{themeCss}}*/
.b { color: blue; }
</style></head><body></body></html>"#;
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style>\n .a { color: red; }\n "),
raw_text_signal("themeCss", false),
raw("\n .b { color: blue; }\n</style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_style_legal_comment_preserved_inline_by_default() {
let mut parser = HtmlParser::new();
let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
parser.parse("test.html", html).expect("parse failed");
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_style_legal_comments_none_strips_legal_comment() {
let mut parser = HtmlParser::with_options(ParserOptions {
legal_comments: LegalComments::None,
..ParserOptions::default()
});
let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
parser.parse("test.html", html).expect("parse failed");
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style> .x { color: red; }</style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_style_element_plain_css_unchanged() {
let mut parser = HtmlParser::new();
let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
let result = parser.parse("test.html", html);
assert!(result.is_ok(), "Parse error: {:?}", result.err());
let fragment_records = parser.into_fragment_records();
assert_stream!(
fragment_records,
"test.html",
[
raw("<html><head>"),
structural_matcher("head_start"),
raw("<style>body { margin: 0; }</style>"),
structural_matcher("head_end"),
raw("</head><body>"),
structural_matcher("body_start"),
structural_matcher("body_end"),
raw("</body></html>"),
]
);
}
#[test]
fn test_unclosed_style_element_returns_error() {
let mut parser = HtmlParser::new();
let result = parser.parse("index.html", "<style>.x { color: red; ");
assert!(matches!(result, Err(ParserError::Template(diag)) if
diag.to_string().contains("unclosed <style> tag")
));
}
#[test]
fn test_text_with_binding_after_void_element() {
let (fragments, _) = parse_and_get_fragments(
"Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
);
assert_fragments!(
fragments,
[
raw("Part A: "),
signal("value1"),
raw("<button>Click</button><br>Part B: "),
signal("value2"),
raw("<button>Click2</button>"),
]
);
}
}