1mod asset_filename;
8mod comment_policy;
9mod component_policy;
10mod component_registry;
11mod condition_parser;
12mod css_light;
13mod css_link;
14mod css_parser;
15mod css_scan;
16mod diagnostic;
17mod error;
18mod handlebars_parser;
19mod html_parser;
20mod outlet_warnings;
21pub mod plugin;
22mod route_parser;
23mod scoped_visits;
24mod suggest;
25
26pub use asset_filename::{
27 AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
28};
29pub use component_registry::{Component, ComponentRegistration, ComponentRegistry};
30pub use condition_parser::ConditionParser;
31pub use css_link::{CssLinkHref, CssLinkOptions, DEFAULT_CSS_FILE_NAME_TEMPLATE};
32pub use css_parser::CssParser;
33pub use diagnostic::{codes, Diagnostic, Severity};
34pub use error::{ParserError, Result};
35pub use handlebars_parser::HandlebarsParser;
36pub use webui_tokens::CssFallbackChain;
37
38use crate::component_policy::{
39 parse_component_render_policy, ComponentRenderPolicy, HYDRATE_ATTR as COMPONENT_HYDRATE_ATTR,
40 RENDER_ATTR as COMPONENT_RENDER_ATTR,
41 RESERVE_BLOCK_SIZE_ATTR as COMPONENT_RESERVE_BLOCK_SIZE_ATTR,
42};
43use crate::html_parser::{self as html, Attrs, Element, Event, Walker};
44use crate::plugin::{
45 AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
46 ElementStartContext, FragmentContext, ParserPlugin, ParserPluginArtifacts,
47};
48use std::collections::{HashMap, HashSet};
49use std::ops::Range;
50use webui_protocol::{
51 web_ui_fragment, web_ui_fragment::Fragment, BoundaryPhase, ConditionExpr, FragmentList,
52 WebUIFragment, WebUIFragmentAttribute, WebUIFragmentRecords, WebUiFragmentBoundary,
53 WebUiFragmentRoute,
54};
55
56const MAX_TEMPLATE_BYTES: usize = 16 * 1024 * 1024;
62
63const MAX_TEMPLATE_DEPTH: usize = 512;
69
70const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
76
77fn structural_signal(value: impl AsRef<str>) -> WebUIFragment {
78 WebUIFragment::signal(
79 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
80 true,
81 )
82}
83
84#[derive(Clone, Copy, PartialEq, Eq)]
88enum FosterContext {
89 Transparent,
91 Hostile,
94 Barrier,
97}
98
99#[inline]
105fn foster_context_of(name: &str) -> FosterContext {
106 match name.len() {
107 2 => {
108 if name.eq_ignore_ascii_case("tr") {
109 FosterContext::Hostile
110 } else if name.eq_ignore_ascii_case("td") || name.eq_ignore_ascii_case("th") {
111 FosterContext::Barrier
112 } else {
113 FosterContext::Transparent
114 }
115 }
116 5 => {
117 if name.eq_ignore_ascii_case("table")
118 || name.eq_ignore_ascii_case("tbody")
119 || name.eq_ignore_ascii_case("thead")
120 || name.eq_ignore_ascii_case("tfoot")
121 {
122 FosterContext::Hostile
123 } else {
124 FosterContext::Transparent
125 }
126 }
127 6 => {
128 if name.eq_ignore_ascii_case("select") {
129 FosterContext::Hostile
130 } else {
131 FosterContext::Transparent
132 }
133 }
134 7 => {
135 if name.eq_ignore_ascii_case("caption") {
136 FosterContext::Barrier
137 } else {
138 FosterContext::Transparent
139 }
140 }
141 8 => {
142 if name.eq_ignore_ascii_case("optgroup") || name.eq_ignore_ascii_case("colgroup") {
143 FosterContext::Hostile
144 } else {
145 FosterContext::Transparent
146 }
147 }
148 _ => FosterContext::Transparent,
149 }
150}
151
152#[inline]
155fn boundary_parent_scope(name: &str) -> Option<&'static str> {
156 match name.len() {
157 3 if name.eq_ignore_ascii_case("xmp") => Some("<xmp> raw-text content"),
158 5 if name.eq_ignore_ascii_case("title") => Some("<title> text content"),
159 6 if name.eq_ignore_ascii_case("iframe") => Some("<iframe> raw-text content"),
160 6 if name.eq_ignore_ascii_case("script") => Some("<script> raw-text content"),
161 7 if name.eq_ignore_ascii_case("noembed") => Some("<noembed> raw-text content"),
162 8 if name.eq_ignore_ascii_case("template") => Some("<template> inert content"),
163 8 if name.eq_ignore_ascii_case("textarea") => Some("<textarea> text content"),
164 8 if name.eq_ignore_ascii_case("noframes") => Some("<noframes> raw-text content"),
165 8 if name.eq_ignore_ascii_case("noscript") => Some("<noscript> inert content"),
166 9 if name.eq_ignore_ascii_case("plaintext") => Some("<plaintext> text content"),
167 _ => None,
168 }
169}
170
171#[inline]
172fn is_html_text_only_scope(scope: Option<&str>) -> bool {
173 matches!(
179 scope,
180 Some(
181 "<xmp> raw-text content"
182 | "<title> text content"
183 | "<iframe> raw-text content"
184 | "<script> raw-text content"
185 | "<noembed> raw-text content"
186 | "<textarea> text content"
187 | "<noframes> raw-text content"
188 | "<noscript> inert content"
189 | "<plaintext> text content"
190 )
191 )
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
197pub enum CssStrategy {
198 #[default]
200 Link,
201 Style,
203 Module,
207}
208
209impl std::fmt::Display for CssStrategy {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 match self {
212 CssStrategy::Link => write!(f, "link"),
213 CssStrategy::Style => write!(f, "style"),
214 CssStrategy::Module => write!(f, "module"),
215 }
216 }
217}
218
219impl std::str::FromStr for CssStrategy {
220 type Err = String;
221
222 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223 match s {
224 "link" => Ok(CssStrategy::Link),
225 "style" => Ok(CssStrategy::Style),
226 "module" => Ok(CssStrategy::Module),
227 other => Err(format!(
228 "Unknown CSS strategy: {other}. Use \"link\", \"style\", or \"module\"."
229 )),
230 }
231 }
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
237pub enum DomStrategy {
238 #[default]
240 Shadow,
241 Light,
243}
244
245impl std::fmt::Display for DomStrategy {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 Self::Shadow => write!(f, "shadow"),
249 Self::Light => write!(f, "light"),
250 }
251 }
252}
253
254impl std::str::FromStr for DomStrategy {
255 type Err = String;
256
257 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
258 match value {
259 "shadow" => Ok(Self::Shadow),
260 "light" => Ok(Self::Light),
261 other => Err(format!(
262 "Unknown DOM strategy: {other}. Use \"shadow\" or \"light\"."
263 )),
264 }
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
270#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
271pub enum LegalComments {
272 None,
274 #[default]
276 Inline,
277}
278
279impl std::fmt::Display for LegalComments {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 match self {
282 LegalComments::None => write!(f, "none"),
283 LegalComments::Inline => write!(f, "inline"),
284 }
285 }
286}
287
288impl std::str::FromStr for LegalComments {
289 type Err = String;
290
291 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
292 match s {
293 "none" => Ok(LegalComments::None),
294 "inline" => Ok(LegalComments::Inline),
295 other => Err(format!(
296 "Unknown legal comments strategy: {other}. Use \"none\" or \"inline\"."
297 )),
298 }
299 }
300}
301
302#[derive(Debug, Clone, Default)]
304pub struct ParserOptions {
305 pub css_strategy: CssStrategy,
307 pub dom_strategy: DomStrategy,
309 pub css_link_options: CssLinkOptions,
311 pub legal_comments: LegalComments,
313}
314
315impl ParserOptions {
316 pub fn try_new(
322 css_strategy: CssStrategy,
323 dom_strategy: DomStrategy,
324 css_file_name_template: &str,
325 css_public_base: Option<&str>,
326 legal_comments: LegalComments,
327 ) -> Result<Self> {
328 let css_link_options = if css_strategy == CssStrategy::Link {
329 CssLinkOptions::try_new(
330 css_file_name_template.to_string(),
331 css_public_base.map(std::string::ToString::to_string),
332 )?
333 } else {
334 CssLinkOptions::default()
335 };
336
337 Ok(Self {
338 css_strategy,
339 dom_strategy,
340 css_link_options,
341 legal_comments,
342 })
343 }
344}
345
346impl From<DomStrategy> for ParserOptions {
347 fn from(dom_strategy: DomStrategy) -> Self {
348 Self {
349 dom_strategy,
350 ..Self::default()
351 }
352 }
353}
354
355impl From<CssStrategy> for ParserOptions {
356 fn from(css_strategy: CssStrategy) -> Self {
357 Self {
358 css_strategy,
359 ..Self::default()
360 }
361 }
362}
363
364impl From<(CssStrategy, DomStrategy)> for ParserOptions {
365 fn from((css_strategy, dom_strategy): (CssStrategy, DomStrategy)) -> Self {
366 Self {
367 css_strategy,
368 dom_strategy,
369 ..Self::default()
370 }
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
377pub enum Plugin {
378 #[cfg_attr(feature = "cli", value(name = "fast"))]
380 Fast,
381 #[cfg_attr(feature = "cli", value(name = "fast-v2"))]
383 FastV2,
384 #[cfg_attr(feature = "cli", value(name = "fast-v3"))]
386 FastV3,
387 #[cfg_attr(feature = "cli", value(name = "webui"))]
389 WebUI,
390}
391
392impl std::fmt::Display for Plugin {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 match self {
395 Plugin::Fast => write!(f, "fast"),
396 Plugin::FastV2 => write!(f, "fast-v2"),
397 Plugin::FastV3 => write!(f, "fast-v3"),
398 Plugin::WebUI => write!(f, "webui"),
399 }
400 }
401}
402
403impl std::str::FromStr for Plugin {
404 type Err = String;
405
406 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
407 match s {
408 "fast" => Ok(Plugin::Fast),
409 "fast-v2" => Ok(Plugin::FastV2),
410 "fast-v3" => Ok(Plugin::FastV3),
411 "webui" => Ok(Plugin::WebUI),
412 other => Err(format!(
413 "Unknown plugin: {other}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
414 )),
415 }
416 }
417}
418
419struct FragmentIdCounter {
421 counters: HashMap<String, usize>,
423}
424
425impl FragmentIdCounter {
426 fn new() -> Self {
428 Self {
429 counters: HashMap::new(),
430 }
431 }
432
433 fn next_id(&mut self, prefix: &str) -> String {
435 let count = self.counters.entry(prefix.to_string()).or_insert(0);
436 *count += 1;
437 format!("{}-{}", prefix, count)
438 }
439
440 fn has_generated(&self, id: &str) -> bool {
441 let Some((prefix, number)) = id.rsplit_once('-') else {
442 return false;
443 };
444 self.counters.get(prefix).is_some_and(|count| {
445 number
446 .parse::<usize>()
447 .is_ok_and(|number| number > 0 && number <= *count)
448 })
449 }
450}
451
452#[derive(Default)]
453struct NamedForLoops {
454 entries: HashMap<String, NamedFor>,
455}
456
457struct NamedFor {
458 item: String,
459 offset: usize,
460 defined: bool,
461}
462
463impl NamedForLoops {
464 fn register(&mut self, owner: &str, id: &str, item: &str, site: (bool, usize)) -> Result<()> {
465 let (definition, offset) = site;
466 if let Some(entry) = self.entries.get_mut(id) {
467 if definition && entry.defined {
468 return Err(named_for_error(
469 owner,
470 codes::DUPLICATE_FOR_ID,
471 "duplicate named <for> definition",
472 id,
473 "define the body once per file and use a self-closing <for id=\"...\" each=\"...\" /> to reuse it",
474 )
475 .into());
476 }
477 if entry.item != item {
478 return Err(named_for_error(
479 owner,
480 codes::INCOMPATIBLE_FOR_ITEM,
481 "inconsistent item variable for named <for>",
482 id,
483 &format!(
484 "use each=\"{} in collection\" at every reference to this id",
485 entry.item
486 ),
487 )
488 .into());
489 }
490 entry.defined |= definition;
491 } else {
492 self.entries.insert(
493 id.to_string(),
494 NamedFor {
495 item: item.to_string(),
496 offset,
497 defined: definition,
498 },
499 );
500 }
501 Ok(())
502 }
503
504 fn validate(&self, owner: &str, source: &str) -> Result<()> {
505 let missing = self
506 .entries
507 .iter()
508 .filter(|(_, entry)| !entry.defined)
509 .min_by_key(|(_, entry)| entry.offset);
510 if let Some((id, entry)) = missing {
511 return Err(named_for_error(
512 owner,
513 codes::UNKNOWN_FOR_ID,
514 "unknown named <for> definition",
515 id,
516 "add a <for> with this id and a body in the same file; definitions in other files are not visible",
517 )
518 .at_offset(source, entry.offset)
519 .into());
520 }
521 Ok(())
522 }
523}
524
525fn parse_for_each<'a>(owner: &str, each: &'a str) -> Result<(&'a str, &'a str)> {
526 let mut parts = each.split_whitespace();
527 let (Some(item), Some("in"), Some(collection), None) =
528 (parts.next(), parts.next(), parts.next(), parts.next())
529 else {
530 return Err(invalid_for_each(owner, each, codes::INVALID_FOR_EACH));
531 };
532 let allowed = |value: &str| {
533 value.bytes().all(|byte| {
534 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' || byte == b'.'
535 })
536 };
537 if !allowed(item) || !allowed(collection) {
538 return Err(invalid_for_each(owner, each, codes::INVALID_FOR_IDENTIFIER));
539 }
540 Ok((item, collection))
541}
542
543fn validate_for_id<'a>(owner: &str, tag: &html::Tag<'a>) -> Result<Option<&'a str>> {
544 let mut id = None;
545 let mut id_count = 0;
546 for attr in tag.attrs() {
547 match attr.name {
548 "template" => return Err(unsupported_for_template(owner)),
549 "id" => {
550 id = Some(attr.value.unwrap_or_default());
551 id_count += 1;
552 }
553 _ => {}
554 }
555 }
556 let Some(id) = id else {
557 return Ok(None);
558 };
559 if id.is_empty()
560 || !id
561 .bytes()
562 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
563 || id_count != 1
564 {
565 return Err(named_for_error(
566 owner,
567 codes::INVALID_FOR_ID,
568 "invalid <for> id",
569 id,
570 "use one non-empty static id containing letters, digits, '_' or '-'",
571 )
572 .into());
573 }
574 Ok(Some(id))
575}
576
577fn strip_condition_braces(value: &str) -> &str {
578 let value = value.trim();
579 value
580 .strip_prefix("{{")
581 .and_then(|value| value.strip_suffix("}}"))
582 .map_or(value, str::trim)
583}
584
585#[cold]
586#[inline(never)]
587fn locate_for_error(error: ParserError, element: &Element<'_>) -> ParserError {
588 match error {
589 ParserError::Template(diagnostic) => (*diagnostic)
590 .at_offset(element.source(), element.start)
591 .into(),
592 other => other,
593 }
594}
595
596#[cold]
597#[inline(never)]
598fn invalid_for_each(owner: &str, each: &str, code: &'static str) -> ParserError {
599 Diagnostic::error("invalid <for> each expression")
600 .code(code)
601 .component(owner)
602 .element("for")
603 .snippet(format!("each=\"{each}\""))
604 .help("use each=\"item in collection\" without braces; item and collection names may use only letters, digits, '_', '-', and '.'")
605 .into()
606}
607
608#[cold]
609#[inline(never)]
610fn unsupported_for_template(owner: &str) -> ParserError {
611 Diagnostic::error("unsupported <for> template attribute")
612 .code(codes::INVALID_FOR_ID)
613 .component(owner)
614 .element("for")
615 .snippet("template")
616 .help("replace template with id on the loop definition and every reference; named loops are local to their file")
617 .into()
618}
619
620#[cold]
621#[inline(never)]
622fn named_for_error(
623 owner: &str,
624 code: &'static str,
625 title: &str,
626 id: &str,
627 help: &str,
628) -> Diagnostic {
629 Diagnostic::error(title)
630 .code(code)
631 .component(owner)
632 .element("for")
633 .snippet(format!("id=\"{id}\""))
634 .help(help)
635}
636
637struct ParseContext {
638 fragments: Vec<WebUIFragment>,
639 raw_buffer: String,
640}
641
642struct PendingBoundary {
643 declaration_id: u32,
644}
645
646struct BoundaryGraphEdge {
647 target: String,
648}
649
650struct RepeatSite<'a> {
652 owner: &'a str,
653 item: &'a str,
654 collection: &'a str,
655}
656
657struct RepeatBoundaryViolation {
659 repeat_owner: String,
660 each: String,
661 boundary_owner: String,
662 boundary_name: String,
663}
664
665fn push_route_records<'a>(targets: &mut Vec<&'a str>, root: &'a WebUiFragmentRoute) {
667 let mut pending = vec![root];
668 while let Some(route) = pending.pop() {
669 targets.push(route.fragment_id.as_str());
670 targets.push(route.content_fragment_id.as_str());
671 targets.push(route.pending_component.as_str());
672 targets.push(route.error_component.as_str());
673 pending.extend(route.children.iter());
674 }
675}
676
677fn push_child_records<'a>(list: &'a FragmentList, targets: &mut Vec<&'a str>) {
679 for fragment in &list.fragments {
680 match fragment.fragment.as_ref() {
681 Some(Fragment::Component(component)) => targets.push(&component.fragment_id),
682 Some(Fragment::IfCond(if_cond)) => targets.push(&if_cond.fragment_id),
683 Some(Fragment::ForLoop(for_loop)) => targets.push(&for_loop.fragment_id),
684 Some(Fragment::Route(route)) => push_route_records(targets, route),
685 _ => {}
686 }
687 }
688}
689
690#[derive(Default)]
691struct FragmentCssTokens {
692 definitions: Vec<String>,
693 fallback_chains: Vec<CssFallbackChain>,
694}
695
696#[derive(Debug, Clone, PartialEq, Eq)]
698pub struct CssTokenAnalysis {
699 pub protocol_tokens: Vec<String>,
701 pub fallback_chains: Vec<CssFallbackChain>,
704 pub(crate) token_sites: HashMap<String, TokenSite>,
708}
709
710#[derive(Debug, Clone, PartialEq, Eq)]
716pub(crate) struct TokenSite {
717 owner: String,
718 position: Option<(usize, usize)>,
719 snippet: Option<String>,
720}
721
722enum ParseOp<'a> {
723 Parse {
724 range: Range<usize>,
725 depth: usize,
726 },
727 EmitClose(&'a str),
728 EndComponent(&'a str),
729 EndHead(&'a str),
730 EndBody(&'a str),
731 CompleteBoundary {
732 boundary: PendingBoundary,
733 },
734 RestoreFosterDepth(u32),
740 RestoreBoundaryParentScope(Option<&'static str>),
743 CompleteFor {
744 parent: ParseContext,
745 item: String,
746 collection: String,
747 fragment_id: String,
748 named_definition: bool,
749 previous_for_depth: usize,
750 },
751 CompleteIf {
752 parent: ParseContext,
753 condition: ConditionExpr,
754 fragment_id: String,
755 },
756}
757
758enum TokenGraphOp<'a> {
759 EnterFragment(&'a str),
760 ExitFragment(&'a str),
761 EnterComponent(&'a str),
762 EnterRoute(&'a WebUiFragmentRoute),
763 ExitDefinitions(&'a [String]),
764}
765
766impl Default for HtmlParser {
767 fn default() -> Self {
768 Self::new()
769 }
770}
771
772pub struct HtmlParser {
774 css_parser: CssParser,
776
777 id_counter: FragmentIdCounter,
779
780 condition_parser: ConditionParser,
782
783 handlebars_parser: HandlebarsParser,
785
786 component_registry: ComponentRegistry,
788
789 fragment_records: WebUIFragmentRecords,
791
792 warnings: Vec<Diagnostic>,
794
795 raw_buffer: String,
797
798 options: ParserOptions,
800
801 plugin: Option<Box<dyn ParserPlugin>>,
803
804 component_processing: ComponentProcessing,
806
807 component_dom_analyses: HashMap<String, ComponentDomAnalysis>,
809
810 token_roots: Vec<String>,
813
814 fragment_css_tokens: HashMap<String, FragmentCssTokens>,
817
818 in_progress_fragments: HashSet<String>,
821
822 current_fragment_id: String,
825
826 loop_owner_indices: HashMap<String, usize>,
827 named_for_loops: NamedForLoops,
828 named_for_fragment_ids: HashSet<String>,
829
830 next_boundary_declaration_id: u32,
832
833 boundary_names_by_owner: HashMap<String, HashSet<String>>,
835
836 in_boundary: bool,
839
840 boundary_ancestor_depth: usize,
845
846 body_depth: usize,
850
851 for_depth: usize,
854
855 route_depth: usize,
857
858 foster_context_depth: u32,
867
868 boundary_parent_scope: Option<&'static str>,
873
874 module_entry_srcs: Vec<String>,
887}
888
889struct BuiltComponentTemplate {
890 ssr: String,
891 artifact: Option<String>,
892 uses_shadow_dom: bool,
893 style: Option<OwnedComponentStyle>,
894}
895
896enum OwnedComponentStyle {
899 Link(String),
900 Inline(String),
901 Adopted(String),
902}
903
904#[derive(Clone, Copy)]
905struct ComponentStyleInjection<'a> {
906 css_snippet: Option<&'a str>,
907 adopted_specifier: Option<&'a str>,
908}
909
910#[derive(Clone, Copy)]
911struct ComponentTemplateMode {
912 preserve_runtime_attrs: bool,
913 policy_wrapper: bool,
914 styles_at_end: bool,
916}
917
918impl BuiltComponentTemplate {
919 fn artifact(&self) -> &str {
920 self.artifact.as_deref().unwrap_or(&self.ssr)
921 }
922
923 fn plugin_context<'a>(&'a self, component: &'a Component) -> ComponentBuildContext<'a> {
924 ComponentBuildContext {
925 component,
926 template: self.artifact(),
927 uses_shadow_dom: self.uses_shadow_dom,
928 style: self.style.as_ref().map(|style| match style {
929 OwnedComponentStyle::Link(href) => plugin::ComponentStyleDelivery::Link { href },
930 OwnedComponentStyle::Inline(css) => plugin::ComponentStyleDelivery::Inline { css },
931 OwnedComponentStyle::Adopted(specifier) => {
932 plugin::ComponentStyleDelivery::Adopted { specifier }
933 }
934 }),
935 }
936 }
937}
938
939#[derive(Debug, Clone, Copy)]
940struct ComponentDomAnalysis {
941 uses_shadow_dom: bool,
942 authored_shadow_root: Option<(usize, usize)>,
944 authored_light_root: Option<(usize, usize)>,
947}
948
949struct ShadowRootModeOccurrence<'a> {
950 element_start: usize,
951 element_end: usize,
952 element_name: &'a str,
953 has_closing_tag: bool,
954 dynamically_bound: bool,
955 attr_offset: usize,
956 value: Option<&'a str>,
957}
958
959#[cfg(test)]
960fn analyze_component_dom(
961 tag_name: &str,
962 source: &str,
963 fallback: DomStrategy,
964) -> Result<ComponentDomAnalysis> {
965 analyze_component_dom_with_diagnostic_source(tag_name, source, fallback, None)
966}
967
968fn analyze_component_dom_with_diagnostic_source(
969 tag_name: &str,
970 source: &str,
971 fallback: DomStrategy,
972 diagnostic_source: Option<&str>,
973) -> Result<ComponentDomAnalysis> {
974 let mut ranges = Vec::with_capacity(8);
975 ranges.push((0..source.len(), true));
976 let mut top_level_element_count = 0usize;
977 let mut top_level_root_start = 0usize;
978 let mut has_top_level_content = false;
979 let mut bare_template_root = None;
980 let mut shadow_mode = None;
981 let mut slot_offset = None;
982
983 while let Some((range, top_level)) = ranges.pop() {
984 for event in Walker::new_range(source, range.start, range.end) {
985 match event {
986 Event::Text(text) if top_level && !text.trim().is_empty() => {
987 has_top_level_content = true;
988 }
989 Event::Declaration(_) if top_level => {
990 has_top_level_content = true;
991 }
992 Event::Element(element) => {
993 if top_level {
994 top_level_element_count += 1;
995 top_level_root_start = element.start;
996 bare_template_root = (element.name().eq_ignore_ascii_case("template")
997 && !element.self_closing()
998 && element.attrs().next().is_none())
999 .then_some((element.content_start, element.content_end));
1000 }
1001
1002 for attr in element.attrs() {
1003 let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1004 if attr_name.eq_ignore_ascii_case("shadowrootmode") {
1005 if shadow_mode.is_some() {
1006 return Err(invalid_shadow_root_mode_error(
1007 tag_name,
1008 DiagnosticSite::new(
1009 source,
1010 diagnostic_source,
1011 element.start + attr.raw_range.start,
1012 ),
1013 "a component may declare only one `shadowrootmode`",
1014 "keep one `shadowrootmode=\"open\"` attribute on the sole top-level `<template>`",
1015 ));
1016 }
1017 shadow_mode = Some(ShadowRootModeOccurrence {
1018 element_start: element.start,
1019 element_end: element.close_end(),
1020 element_name: element.name(),
1021 has_closing_tag: !element.self_closing()
1022 && element.close_end() > element.content_end(),
1023 dynamically_bound: attr.name != attr_name,
1024 attr_offset: element.start + attr.raw_range.start,
1025 value: attr.value,
1026 });
1027 }
1028 }
1029
1030 if slot_offset.is_none() && element.name().eq_ignore_ascii_case("slot") {
1031 slot_offset = Some(element.start);
1032 }
1033
1034 let name = element.name();
1035 let raw_text = name.eq_ignore_ascii_case("script")
1036 || name.eq_ignore_ascii_case("style")
1037 || name.eq_ignore_ascii_case("textarea")
1038 || name.eq_ignore_ascii_case("title");
1039 if element.content_end() > element.inner().start && !raw_text {
1040 ranges.push((element.inner(), false));
1041 }
1042 }
1043 Event::Text(_)
1044 | Event::Comment(_)
1045 | Event::Declaration(_)
1046 | Event::ClosingTag(_) => {}
1047 }
1048 }
1049 }
1050
1051 let authored_shadow_root = match shadow_mode {
1052 Some(mode) => {
1053 let value = mode.value;
1054 if mode.dynamically_bound {
1055 return Err(invalid_shadow_root_mode_error(
1056 tag_name,
1057 DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1058 "`shadowrootmode` must be a static attribute",
1059 "replace the binding with `shadowrootmode=\"open\"` on the sole top-level `<template>`",
1060 ));
1061 }
1062 if value.is_some_and(|value| value.eq_ignore_ascii_case("closed")) {
1063 return Err(invalid_shadow_root_mode_error(
1064 tag_name,
1065 DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1066 "`shadowrootmode=\"closed\"` is not supported",
1067 "use `shadowrootmode=\"open\"`; WebUI requires an open root for hydration and style delivery",
1068 ));
1069 }
1070 if !value.is_some_and(|value| value.eq_ignore_ascii_case("open")) {
1071 return Err(invalid_shadow_root_mode_error(
1072 tag_name,
1073 DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1074 "`shadowrootmode` must be `open`",
1075 "set the attribute to `shadowrootmode=\"open\"`",
1076 ));
1077 }
1078 if top_level_element_count != 1
1079 || has_top_level_content
1080 || mode.element_start != top_level_root_start
1081 || !mode.element_name.eq_ignore_ascii_case("template")
1082 || !mode.has_closing_tag
1083 {
1084 return Err(invalid_shadow_root_mode_error(
1085 tag_name,
1086 DiagnosticSite::new(source, diagnostic_source, mode.attr_offset),
1087 "the declarative Shadow DOM wrapper must be the sole top-level element",
1088 "wrap the complete component in one top-level `<template shadowrootmode=\"open\">`",
1089 ));
1090 }
1091 Some((mode.element_start, mode.element_end))
1092 }
1093 None => None,
1094 };
1095
1096 let authored_light_root =
1097 if authored_shadow_root.is_none() && top_level_element_count == 1 && !has_top_level_content
1098 {
1099 bare_template_root
1100 } else {
1101 None
1102 };
1103 let uses_shadow_dom = authored_shadow_root.is_some()
1104 || (authored_light_root.is_none() && fallback == DomStrategy::Shadow);
1105 if !uses_shadow_dom {
1106 if let Some(offset) = slot_offset {
1107 return Err(light_dom_slot_error(
1108 tag_name,
1109 DiagnosticSite::new(source, diagnostic_source, offset),
1110 ));
1111 }
1112 }
1113
1114 Ok(ComponentDomAnalysis {
1115 uses_shadow_dom,
1116 authored_shadow_root,
1117 authored_light_root,
1118 })
1119}
1120
1121#[cold]
1122#[inline(never)]
1123fn invalid_shadow_root_mode_error(
1124 tag_name: &str,
1125 site: DiagnosticSite<'_>,
1126 title: &str,
1127 help: &str,
1128) -> ParserError {
1129 let (source, offset) = site.resolve(DiagnosticTarget::Attribute("shadowrootmode"));
1130 Diagnostic::error(title)
1131 .code(codes::INVALID_SHADOW_ROOT_MODE)
1132 .component(tag_name)
1133 .at_offset(source, offset)
1134 .snippet(source_line_snippet(source, offset))
1135 .help(help)
1136 .into()
1137}
1138
1139#[cold]
1140#[inline(never)]
1141fn light_dom_slot_error(tag_name: &str, site: DiagnosticSite<'_>) -> ParserError {
1142 let (source, offset) = site.resolve(DiagnosticTarget::Element("slot"));
1143 Diagnostic::error("native `<slot>` projection requires Shadow DOM")
1144 .code(codes::LIGHT_DOM_SLOT)
1145 .component(tag_name)
1146 .element("slot")
1147 .at_offset(source, offset)
1148 .snippet(source_line_snippet(source, offset))
1149 .help(
1150 "wrap the complete component template in `<template shadowrootmode=\"open\">` to use `<slot>`",
1151 )
1152 .into()
1153}
1154
1155#[derive(Clone, Copy)]
1156struct DiagnosticSite<'a> {
1157 source: &'a str,
1158 authored_source: Option<&'a str>,
1159 offset: usize,
1160}
1161
1162impl<'a> DiagnosticSite<'a> {
1163 fn new(source: &'a str, authored_source: Option<&'a str>, offset: usize) -> Self {
1164 Self {
1165 source,
1166 authored_source,
1167 offset,
1168 }
1169 }
1170
1171 fn resolve(self, target: DiagnosticTarget) -> (&'a str, usize) {
1172 let Some(authored_source) = self.authored_source else {
1173 return (self.source, self.offset);
1174 };
1175 let transformed_offsets = diagnostic_target_offsets(self.source, target);
1176 let occurrence = transformed_offsets
1177 .iter()
1178 .position(|offset| *offset == self.offset)
1179 .unwrap_or(0);
1180 let authored_offsets = diagnostic_target_offsets(authored_source, target);
1181 authored_offsets
1182 .get(occurrence)
1183 .copied()
1184 .map_or((self.source, self.offset), |offset| {
1185 (authored_source, offset)
1186 })
1187 }
1188}
1189
1190#[derive(Clone, Copy)]
1191enum DiagnosticTarget {
1192 Element(&'static str),
1193 Attribute(&'static str),
1194}
1195
1196#[cold]
1197#[inline(never)]
1198fn diagnostic_target_offsets(source: &str, target: DiagnosticTarget) -> Vec<usize> {
1199 let mut offsets = Vec::new();
1200 let mut ranges = Vec::with_capacity(4);
1201 ranges.push(0..source.len());
1202
1203 while let Some(range) = ranges.pop() {
1204 for event in Walker::new_range(source, range.start, range.end) {
1205 let Event::Element(element) = event else {
1206 continue;
1207 };
1208 match target {
1209 DiagnosticTarget::Element(name) if element.name().eq_ignore_ascii_case(name) => {
1210 offsets.push(element.start);
1211 }
1212 DiagnosticTarget::Attribute(name) => {
1213 for attr in element.attrs() {
1214 let attr_name = attr.name.strip_prefix([':', '?']).unwrap_or(attr.name);
1215 if attr_name.eq_ignore_ascii_case(name) {
1216 offsets.push(element.start + attr.raw_range.start);
1217 }
1218 }
1219 }
1220 DiagnosticTarget::Element(_) => {}
1221 }
1222
1223 let name = element.name();
1224 let raw_text = name.eq_ignore_ascii_case("script")
1225 || name.eq_ignore_ascii_case("style")
1226 || name.eq_ignore_ascii_case("textarea")
1227 || name.eq_ignore_ascii_case("title");
1228 if element.content_end() > element.inner().start && !raw_text {
1229 ranges.push(element.inner());
1230 }
1231 }
1232 }
1233 offsets.sort_unstable();
1234 offsets
1235}
1236
1237struct ComponentTemplateInput<'a> {
1238 tag_name: &'a str,
1239 html: &'a str,
1240 artifact_html: Option<&'a str>,
1241 authored_html: Option<&'a str>,
1242 css_content: Option<&'a str>,
1243 artifact_needed: bool,
1244}
1245
1246fn add_token_definitions<'a>(
1247 definitions: &'a [String],
1248 available_counts: &mut HashMap<&'a str, usize>,
1249) {
1250 for definition in definitions {
1251 let count = available_counts.entry(definition.as_str()).or_insert(0);
1252 *count += 1;
1253 }
1254}
1255
1256fn remove_token_definitions(definitions: &[String], available_counts: &mut HashMap<&str, usize>) {
1257 for definition in definitions {
1258 if let Some(count) = available_counts.get_mut(definition.as_str()) {
1259 if *count == 1 {
1260 available_counts.remove(definition.as_str());
1261 } else {
1262 *count -= 1;
1263 }
1264 }
1265 }
1266}
1267
1268#[derive(Default)]
1272struct UnresolvedTokens {
1273 chains: Vec<CssFallbackChain>,
1274 sites: HashMap<String, TokenSite>,
1275}
1276
1277fn record_unresolved_requirements(
1278 source: &[CssFallbackChain],
1279 available_counts: &HashMap<&str, usize>,
1280 owner: &str,
1281 css_source: Option<&str>,
1282 out: &mut UnresolvedTokens,
1283) {
1284 for requirement in source {
1285 let tokens: Vec<String> = requirement
1286 .tokens
1287 .iter()
1288 .filter(|token| !available_counts.contains_key(token.as_str()))
1289 .cloned()
1290 .collect();
1291 if tokens.is_empty() {
1292 continue;
1293 }
1294 for token in &tokens {
1295 if !out.sites.contains_key(token) {
1296 out.sites
1297 .insert(token.clone(), token_site(owner, css_source, token));
1298 }
1299 }
1300 out.chains.push(CssFallbackChain {
1301 tokens,
1302 has_literal_fallback: requirement.has_literal_fallback,
1303 });
1304 }
1305}
1306
1307fn token_site(owner: &str, css_source: Option<&str>, token: &str) -> TokenSite {
1311 let (position, snippet) = match css_source.and_then(|css| locate_css_token(css, token)) {
1312 Some((line, column, snippet)) => (Some((line, column)), Some(snippet)),
1313 None => (None, None),
1314 };
1315 TokenSite {
1316 owner: owner.to_string(),
1317 position,
1318 snippet,
1319 }
1320}
1321
1322fn locate_css_token(css: &str, token: &str) -> Option<(usize, usize, String)> {
1328 let bytes = css.as_bytes();
1329 let name = token.as_bytes();
1330 let mut index = 0;
1331 while index + 2 + name.len() <= bytes.len() {
1332 let is_prefixed = bytes[index] == b'-'
1333 && bytes[index + 1] == b'-'
1334 && bytes.get(index + 2..index + 2 + name.len()) == Some(name);
1335 if is_prefixed {
1336 let after = index + 2 + name.len();
1337 let is_exact_name = bytes
1338 .get(after)
1339 .is_none_or(|b| !(b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_'));
1340 if is_exact_name {
1341 let mut cursor = after;
1342 while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
1343 cursor += 1;
1344 }
1345 if bytes.get(cursor) != Some(&b':') {
1347 let (line, column) = diagnostic::line_column(css, index);
1348 return Some((line, column, source_line_snippet(css, index)));
1349 }
1350 }
1351 }
1352 index += 1;
1353 }
1354 None
1355}
1356
1357fn source_line_snippet(source: &str, offset: usize) -> String {
1360 const MAX_SNIPPET: usize = 80;
1361 let start = source[..offset].rfind('\n').map_or(0, |n| n + 1);
1362 let end = source[offset..]
1363 .find('\n')
1364 .map_or(source.len(), |n| offset + n);
1365 let line = source[start..end].trim();
1366 if line.chars().count() > MAX_SNIPPET {
1367 let truncated: String = line.chars().take(MAX_SNIPPET).collect();
1368 format!("{truncated}…")
1369 } else {
1370 line.to_string()
1371 }
1372}
1373
1374fn css_owner_label(tag_name: &str) -> String {
1377 let mut label = String::with_capacity(tag_name.len() + 4);
1378 label.push_str(tag_name);
1379 label.push_str(".css");
1380 label
1381}
1382
1383impl CssTokenAnalysis {
1384 pub fn validate_theme_tokens(&self, theme: &webui_tokens::TokenFile) -> Result<()> {
1401 webui_tokens::validate_chain_tokens(&self.fallback_chains, theme)
1402 .map_err(|source| self.theme_token_error(source, theme))
1403 }
1404
1405 #[must_use]
1410 pub fn unthemed_literal_fallback_tokens(&self, theme: &webui_tokens::TokenFile) -> Vec<String> {
1411 webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme)
1412 }
1413
1414 #[must_use]
1422 pub fn theme_token_warnings(&self, theme: &webui_tokens::TokenFile) -> Vec<Diagnostic> {
1423 let mut warnings = Vec::new();
1424 for token in webui_tokens::unthemed_literal_fallback_tokens(&self.fallback_chains, theme) {
1425 let help = match closest_theme_token(&token, theme.themes.values()) {
1426 Some(suggestion) => {
1427 format!("did you mean --{suggestion}? otherwise the literal fallback is used")
1428 }
1429 None => format!(
1430 "define --{token} in the theme, or keep its literal fallback if intentional"
1431 ),
1432 };
1433 let diag = self.locate_diagnostic(
1434 Diagnostic::warning(format!("unthemed CSS token --{token}"))
1435 .code(codes::UNTHEMED_TOKEN)
1436 .help(help),
1437 &token,
1438 );
1439 warnings.push(diag);
1440 }
1441 warnings
1442 }
1443
1444 #[cold]
1448 #[inline(never)]
1449 fn theme_token_error(
1450 &self,
1451 source: webui_tokens::TokenError,
1452 theme: &webui_tokens::TokenFile,
1453 ) -> ParserError {
1454 match source {
1455 webui_tokens::TokenError::MissingToken {
1456 theme: theme_name,
1457 token,
1458 } => {
1459 let help =
1463 match closest_theme_token(&token, theme.themes.get(&theme_name).into_iter()) {
1464 Some(suggestion) => {
1465 format!("did you mean --{suggestion}? otherwise define it locally")
1466 }
1467 None => {
1468 "define it locally if it should not come from the theme".to_string()
1469 }
1470 };
1471 self.locate_diagnostic(
1472 Diagnostic::error("missing theme token")
1473 .code(codes::MISSING_THEME_TOKEN)
1474 .help(help),
1475 &token,
1476 )
1477 .into()
1478 }
1479 other => ParserError::Generic(format!("Theme token validation failed: {other}")),
1480 }
1481 }
1482
1483 fn locate_diagnostic(&self, diag: Diagnostic, token: &str) -> Diagnostic {
1486 let Some(site) = self.token_sites.get(token) else {
1487 return diag.snippet(format!("--{token}"));
1488 };
1489 let mut diag = diag.component(site.owner.clone());
1490 if let Some((line, column)) = site.position {
1491 diag = diag.position(line, column);
1492 }
1493 match &site.snippet {
1494 Some(snippet) => diag.snippet(snippet.clone()),
1495 None => diag.snippet(format!("--{token}")),
1496 }
1497 }
1498}
1499
1500#[cold]
1503#[inline(never)]
1504fn closest_theme_token<'a>(
1505 target: &str,
1506 themes: impl Iterator<Item = &'a std::collections::HashMap<String, String>>,
1507) -> Option<String> {
1508 let mut names: Vec<&str> = themes
1509 .flat_map(|theme| theme.keys().map(String::as_str))
1510 .collect();
1511 names.sort_unstable();
1512 names.dedup();
1513 crate::suggest::closest_match(target, names.into_iter()).map(ToOwned::to_owned)
1514}
1515
1516impl HtmlParser {
1517 #[must_use]
1519 pub fn new() -> Self {
1520 Self::with_options(ParserOptions::default())
1521 }
1522
1523 #[must_use]
1525 pub fn with_options(options: impl Into<ParserOptions>) -> Self {
1526 let options = options.into();
1527
1528 Self {
1529 component_registry: ComponentRegistry::with_legal_comments(options.legal_comments),
1530 css_parser: CssParser::new(),
1531 id_counter: FragmentIdCounter::new(),
1532 condition_parser: ConditionParser::new(),
1533 handlebars_parser: HandlebarsParser::new(),
1534 raw_buffer: String::new(),
1535 fragment_records: WebUIFragmentRecords::new(),
1536 warnings: Vec::new(),
1537 options,
1538 plugin: None,
1539 component_processing: ComponentProcessing::default(),
1540 component_dom_analyses: HashMap::new(),
1541 token_roots: Vec::new(),
1542 fragment_css_tokens: HashMap::new(),
1543 in_progress_fragments: HashSet::new(),
1544 current_fragment_id: String::new(),
1545 loop_owner_indices: HashMap::new(),
1546 named_for_loops: NamedForLoops::default(),
1547 named_for_fragment_ids: HashSet::new(),
1548 next_boundary_declaration_id: 0,
1549 boundary_names_by_owner: HashMap::new(),
1550 in_boundary: false,
1551 boundary_ancestor_depth: 0,
1552 body_depth: 0,
1553 for_depth: 0,
1554 route_depth: 0,
1555 foster_context_depth: 0,
1556 boundary_parent_scope: None,
1557 module_entry_srcs: Vec::new(),
1558 }
1559 }
1560
1561 #[must_use]
1563 pub fn with_plugin(plugin: Box<dyn ParserPlugin>) -> Self {
1564 Self::with_plugin_options(plugin, ParserOptions::default())
1565 }
1566
1567 #[must_use]
1569 pub fn with_plugin_options(
1570 mut plugin: Box<dyn ParserPlugin>,
1571 options: impl Into<ParserOptions>,
1572 ) -> Self {
1573 let mut p = Self::with_options(options);
1574 plugin.configure_parser(&p.options);
1575 let component_processing = plugin.component_processing();
1576 p.component_registry
1577 .set_component_source_transform(component_processing.source_transform);
1578 p.component_processing = component_processing;
1579 p.plugin = Some(plugin);
1580 p
1581 }
1582
1583 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
1585 &mut self.component_registry
1586 }
1587
1588 #[must_use]
1590 pub fn component_registry(&self) -> &ComponentRegistry {
1591 &self.component_registry
1592 }
1593
1594 pub fn into_fragment_records(mut self) -> WebUIFragmentRecords {
1595 std::mem::take(&mut self.fragment_records)
1596 }
1597
1598 #[must_use]
1600 pub fn take_warnings(&mut self) -> Vec<Diagnostic> {
1601 std::mem::take(&mut self.warnings)
1602 }
1603
1604 pub fn has_fragment(&self, fragment_id: &str) -> bool {
1606 self.fragment_records.contains_key(fragment_id)
1607 }
1608
1609 #[cold]
1619 #[inline(never)]
1620 fn finalize_boundary_metadata(&mut self) -> Result<()> {
1621 if self.next_boundary_declaration_id != 0 {
1622 let (outgoing, direct_boundary_records) = self.boundary_graph();
1623 self.mark_boundary_records(&outgoing, direct_boundary_records);
1624 if let Some(violation) = self.find_repeat_reachable_boundary() {
1625 return Err(Self::repeat_reachable_boundary_error(&violation));
1626 }
1627 let occurrence_counts = self.boundary_occurrence_counts(&outgoing);
1628
1629 let mut unkeyed_repeat: Option<(u32, String, String)> = None;
1630 for (fragment_id, list) in &mut self.fragment_records {
1631 let repeated = occurrence_counts
1632 .get(fragment_id)
1633 .is_some_and(|count| *count > 1);
1634 if !repeated {
1635 continue;
1636 }
1637 for fragment in &mut list.fragments {
1638 if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_mut() {
1639 if boundary.phase() == BoundaryPhase::Start {
1640 boundary.may_repeat = true;
1641 if boundary.key.is_none()
1642 && unkeyed_repeat
1643 .as_ref()
1644 .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1645 {
1646 unkeyed_repeat = Some((
1647 boundary.declaration_id,
1648 boundary.owner_fragment_id.clone(),
1649 boundary.name.clone(),
1650 ));
1651 }
1652 }
1653 }
1654 }
1655 }
1656 if let Some((_, owner, name)) = unkeyed_repeat {
1657 return Err(Self::missing_boundary_key_error(&owner, &name));
1658 }
1659
1660 if let Some((owner, name)) = self.find_transitively_nested_boundary() {
1661 return Err(Self::transitively_nested_boundary_error(&owner, &name));
1662 }
1663 }
1664 self.finalize_component_span_signals();
1665 Ok(())
1666 }
1667
1668 fn finalize_component_span_signals(&mut self) {
1669 const START: &str = "streaming_span_start:";
1670 const END: &str = "streaming_span_end:";
1671
1672 let spanning: HashSet<String> = self
1673 .fragment_records
1674 .iter()
1675 .filter_map(|(id, list)| list.contains_boundary.then_some(id.clone()))
1676 .collect();
1677 for list in self.fragment_records.values_mut() {
1678 let mut normalized = Vec::with_capacity(list.fragments.len());
1679 for fragment in list.fragments.drain(..) {
1680 let remove = match fragment.fragment.as_ref() {
1681 Some(Fragment::Signal(signal)) => signal
1682 .value
1683 .strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1684 .and_then(|value| {
1685 value
1686 .strip_prefix(START)
1687 .or_else(|| value.strip_prefix(END))
1688 })
1689 .is_some_and(|tag| !spanning.contains(tag)),
1690 _ => false,
1691 };
1692 if remove {
1693 continue;
1694 }
1695 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
1696 if let Some(WebUIFragment {
1697 fragment: Some(Fragment::Raw(previous)),
1698 }) = normalized.last_mut()
1699 {
1700 previous.value.push_str(&raw.value);
1701 continue;
1702 }
1703 }
1704 normalized.push(fragment);
1705 }
1706 list.fragments = normalized;
1707 }
1708 }
1709
1710 fn boundary_graph(&self) -> (HashMap<String, Vec<BoundaryGraphEdge>>, Vec<String>) {
1711 let mut outgoing: HashMap<String, Vec<BoundaryGraphEdge>> =
1712 HashMap::with_capacity(self.fragment_records.len());
1713 let mut direct_boundary_records = Vec::new();
1714
1715 for (fragment_id, list) in &self.fragment_records {
1716 let edges = outgoing.entry(fragment_id.clone()).or_default();
1717 for fragment in &list.fragments {
1718 match fragment.fragment.as_ref() {
1719 Some(Fragment::Component(component)) => {
1720 Self::push_boundary_edge(edges, &component.fragment_id);
1721 }
1722 Some(Fragment::ForLoop(for_loop)) => {
1723 Self::push_boundary_edge(edges, &for_loop.fragment_id);
1724 }
1725 Some(Fragment::IfCond(if_cond)) => {
1726 Self::push_boundary_edge(edges, &if_cond.fragment_id);
1727 }
1728 Some(Fragment::Boundary(boundary)) => {
1729 if boundary.phase() == BoundaryPhase::Start {
1730 direct_boundary_records.push(fragment_id.clone());
1731 }
1732 }
1733 Some(Fragment::Route(route)) => {
1734 Self::push_route_boundary_edges(edges, route);
1735 }
1736 _ => {}
1737 }
1738 }
1739 }
1740 (outgoing, direct_boundary_records)
1741 }
1742
1743 fn push_boundary_edge(edges: &mut Vec<BoundaryGraphEdge>, target: &str) {
1744 if !target.is_empty() {
1745 edges.push(BoundaryGraphEdge {
1746 target: target.to_string(),
1747 });
1748 }
1749 }
1750
1751 fn push_route_boundary_edges(edges: &mut Vec<BoundaryGraphEdge>, root: &WebUiFragmentRoute) {
1752 let mut pending = vec![root];
1753 while let Some(route) = pending.pop() {
1754 Self::push_boundary_edge(edges, &route.fragment_id);
1755 Self::push_boundary_edge(edges, &route.content_fragment_id);
1756 Self::push_boundary_edge(edges, &route.pending_component);
1757 Self::push_boundary_edge(edges, &route.error_component);
1758 pending.extend(route.children.iter());
1759 }
1760 }
1761
1762 fn mark_boundary_records(
1763 &mut self,
1764 outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1765 direct_boundary_records: Vec<String>,
1766 ) {
1767 for list in self.fragment_records.values_mut() {
1768 list.contains_boundary = false;
1769 }
1770
1771 let mut reverse: HashMap<&str, Vec<&str>> = HashMap::new();
1772 for (parent, edges) in outgoing {
1773 for edge in edges {
1774 reverse
1775 .entry(edge.target.as_str())
1776 .or_default()
1777 .push(parent.as_str());
1778 }
1779 }
1780
1781 let mut marked = HashSet::with_capacity(self.fragment_records.len());
1782 let mut pending = direct_boundary_records;
1783 while let Some(fragment_id) = pending.pop() {
1784 if !marked.insert(fragment_id.clone()) {
1785 continue;
1786 }
1787 if let Some(list) = self.fragment_records.get_mut(&fragment_id) {
1788 list.contains_boundary = true;
1789 }
1790 if let Some(parents) = reverse.get(fragment_id.as_str()) {
1791 pending.extend(parents.iter().map(|parent| (*parent).to_string()));
1792 }
1793 }
1794 }
1795
1796 fn boundary_occurrence_counts(
1797 &self,
1798 outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1799 ) -> HashMap<String, u8> {
1800 let mut incoming: HashMap<&str, Vec<&str>> = HashMap::new();
1801 for (parent, edges) in outgoing {
1802 for edge in edges {
1803 incoming
1804 .entry(edge.target.as_str())
1805 .or_default()
1806 .push(parent.as_str());
1807 }
1808 }
1809
1810 let mut aggregate = HashMap::with_capacity(self.fragment_records.len());
1811 for root in &self.token_roots {
1812 if !self.fragment_records.contains_key(root) {
1813 continue;
1814 }
1815 let counts = self.boundary_occurrences_from_root(root.as_str(), outgoing, &incoming);
1816 for (fragment_id, count) in counts {
1817 let current = aggregate.entry(fragment_id).or_insert(0);
1818 *current = (*current).max(count);
1819 }
1820 }
1821 aggregate
1822 }
1823
1824 fn boundary_occurrences_from_root(
1825 &self,
1826 root: &str,
1827 outgoing: &HashMap<String, Vec<BoundaryGraphEdge>>,
1828 incoming: &HashMap<&str, Vec<&str>>,
1829 ) -> HashMap<String, u8> {
1830 let mut counts: HashMap<String, u8> = HashMap::with_capacity(self.fragment_records.len());
1831 counts.insert(root.to_string(), 1);
1832 let mut pending = vec![root.to_string()];
1833 while let Some(parent) = pending.pop() {
1834 let Some(edges) = outgoing.get(&parent) else {
1835 continue;
1836 };
1837 for edge in edges {
1838 let next = Self::recompute_boundary_occurrences(
1839 edge.target.as_str(),
1840 root,
1841 incoming,
1842 &counts,
1843 );
1844 let current = counts.get(&edge.target).copied().unwrap_or(0);
1845 if next > current {
1846 counts.insert(edge.target.clone(), next);
1847 pending.push(edge.target.clone());
1848 }
1849 }
1850 }
1851 counts
1852 }
1853
1854 fn recompute_boundary_occurrences(
1855 fragment_id: &str,
1856 root: &str,
1857 incoming: &HashMap<&str, Vec<&str>>,
1858 counts: &HashMap<String, u8>,
1859 ) -> u8 {
1860 let mut count = u8::from(fragment_id == root);
1861 let Some(parents) = incoming.get(fragment_id) else {
1862 return count;
1863 };
1864 for parent in parents {
1865 let parent_count = counts.get(*parent).copied().unwrap_or(0);
1866 count = count.saturating_add(parent_count).min(2);
1867 if count == 2 {
1868 break;
1869 }
1870 }
1871 count
1872 }
1873
1874 fn find_repeat_reachable_boundary(&self) -> Option<RepeatBoundaryViolation> {
1883 let mut origin: HashMap<&str, &str> = HashMap::new();
1884 let mut pending: Vec<&str> = Vec::new();
1885 let mut repeats: HashMap<&str, RepeatSite<'_>> = HashMap::new();
1886 let mut seeds: Vec<(&str, RepeatSite<'_>)> = Vec::new();
1887
1888 for (owner, list) in &self.fragment_records {
1889 for fragment in &list.fragments {
1890 let Some(Fragment::ForLoop(for_loop)) = fragment.fragment.as_ref() else {
1891 continue;
1892 };
1893 if for_loop.fragment_id.is_empty() {
1894 continue;
1895 }
1896 let body = for_loop.fragment_id.as_str();
1897 seeds.push((
1898 body,
1899 RepeatSite {
1900 owner: owner.as_str(),
1901 item: for_loop.item.as_str(),
1902 collection: for_loop.collection.as_str(),
1903 },
1904 ));
1905 }
1906 }
1907 seeds.sort_unstable_by(|(left_body, left), (right_body, right)| {
1908 (left.owner, left.item, left.collection, *left_body).cmp(&(
1909 right.owner,
1910 right.item,
1911 right.collection,
1912 *right_body,
1913 ))
1914 });
1915 for (body, site) in seeds {
1916 if !origin.contains_key(body) {
1917 repeats.insert(body, site);
1918 origin.insert(body, body);
1919 pending.push(body);
1920 }
1921 }
1922 pending.reverse();
1925 if pending.is_empty() {
1926 return None;
1927 }
1928
1929 let mounts = self.route_mount_targets();
1930 let mut found: Option<(u32, &str, &WebUiFragmentBoundary)> = None;
1931 let mut targets: Vec<&str> = Vec::new();
1932 while let Some(id) = pending.pop() {
1933 let Some(seed) = origin.get(id).copied() else {
1934 continue;
1935 };
1936 let Some(list) = self.fragment_records.get(id) else {
1937 continue;
1938 };
1939 for fragment in &list.fragments {
1940 if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
1941 if boundary.phase() == BoundaryPhase::Start
1942 && found
1943 .as_ref()
1944 .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
1945 {
1946 found = Some((boundary.declaration_id, seed, boundary));
1947 }
1948 }
1949 }
1950 targets.clear();
1951 push_child_records(list, &mut targets);
1952 if let Some(mounted) = mounts.get(id) {
1953 targets.extend(mounted.iter().copied());
1954 }
1955 for target in targets.drain(..) {
1956 if target.is_empty() {
1957 continue;
1958 }
1959 if !origin.contains_key(target) {
1960 origin.insert(target, seed);
1961 pending.push(target);
1962 }
1963 }
1964 }
1965
1966 found.map(|(_, seed, boundary)| {
1967 let site = repeats.get(seed);
1968 RepeatBoundaryViolation {
1969 repeat_owner: site.map_or_else(String::new, |site| site.owner.to_string()),
1970 each: site.map_or_else(String::new, |site| {
1971 format!("{} in {}", site.item, site.collection)
1972 }),
1973 boundary_owner: boundary.owner_fragment_id.clone(),
1974 boundary_name: boundary.name.clone(),
1975 }
1976 })
1977 }
1978
1979 fn route_mount_targets(&self) -> HashMap<&str, Vec<&str>> {
1988 let outlet_hosts = self.outlet_host_records();
1989 if outlet_hosts.is_empty() {
1990 return HashMap::new();
1991 }
1992
1993 let mut mounts: HashMap<&str, Vec<&str>> = HashMap::new();
1994 let mut children: Vec<&str> = Vec::new();
1995 let mut closure: HashSet<&str> = HashSet::new();
1996 let mut pending: Vec<&str> = Vec::new();
1997 let mut targets: Vec<&str> = Vec::new();
1998 for list in self.fragment_records.values() {
1999 for fragment in &list.fragments {
2000 let Some(Fragment::Route(root)) = fragment.fragment.as_ref() else {
2001 continue;
2002 };
2003 let mut routes = vec![root];
2004 while let Some(route) = routes.pop() {
2005 routes.extend(route.children.iter());
2006 if route.children.is_empty() {
2007 continue;
2008 }
2009 children.clear();
2010 for child in &route.children {
2011 push_route_records(&mut children, child);
2012 }
2013 closure.clear();
2014 pending.clear();
2015 for host in [
2016 route.fragment_id.as_str(),
2017 route.content_fragment_id.as_str(),
2018 ] {
2019 if !host.is_empty() && closure.insert(host) {
2020 pending.push(host);
2021 }
2022 }
2023 while let Some(id) = pending.pop() {
2024 if outlet_hosts.contains(id) {
2025 mounts
2026 .entry(id)
2027 .or_default()
2028 .extend(children.iter().copied());
2029 }
2030 let Some(list) = self.fragment_records.get(id) else {
2031 continue;
2032 };
2033 targets.clear();
2034 push_child_records(list, &mut targets);
2035 for target in targets.drain(..) {
2036 if !target.is_empty() && closure.insert(target) {
2037 pending.push(target);
2038 }
2039 }
2040 }
2041 }
2042 }
2043 }
2044 mounts
2045 }
2046
2047 fn outlet_host_records(&self) -> HashSet<&str> {
2049 self.fragment_records
2050 .iter()
2051 .filter(|(_, list)| {
2052 list.fragments
2053 .iter()
2054 .any(|fragment| matches!(fragment.fragment.as_ref(), Some(Fragment::Outlet(_))))
2055 })
2056 .map(|(id, _)| id.as_str())
2057 .collect()
2058 }
2059
2060 fn find_transitively_nested_boundary(&self) -> Option<(String, String)> {
2066 let mut nested: Option<(u32, &str, &str)> = None;
2067 for list in self.fragment_records.values() {
2068 let mut active: Option<&WebUiFragmentBoundary> = None;
2069 for fragment in &list.fragments {
2070 let Some(boundary) = active else {
2071 if let Some(Fragment::Boundary(boundary)) = fragment.fragment.as_ref() {
2072 if boundary.phase() == BoundaryPhase::Start {
2073 active = Some(boundary);
2074 }
2075 }
2076 continue;
2077 };
2078 if let Some(Fragment::Boundary(marker)) = fragment.fragment.as_ref() {
2079 if marker.phase() == BoundaryPhase::End {
2080 active = None;
2081 }
2082 continue;
2083 }
2084 if !self.fragment_reaches_boundary(fragment) {
2085 continue;
2086 }
2087 if nested
2088 .as_ref()
2089 .is_none_or(|(id, _, _)| boundary.declaration_id < *id)
2090 {
2091 nested = Some((
2092 boundary.declaration_id,
2093 boundary.owner_fragment_id.as_str(),
2094 boundary.name.as_str(),
2095 ));
2096 }
2097 }
2098 }
2099 nested.map(|(_, owner, name)| (owner.to_string(), name.to_string()))
2100 }
2101
2102 fn fragment_reaches_boundary(&self, fragment: &WebUIFragment) -> bool {
2105 let reaches = |id: &str| {
2106 !id.is_empty()
2107 && self
2108 .fragment_records
2109 .get(id)
2110 .is_some_and(|list| list.contains_boundary)
2111 };
2112 match fragment.fragment.as_ref() {
2113 Some(Fragment::Component(component)) => reaches(&component.fragment_id),
2114 Some(Fragment::ForLoop(for_loop)) => reaches(&for_loop.fragment_id),
2115 Some(Fragment::IfCond(if_cond)) => reaches(&if_cond.fragment_id),
2116 Some(Fragment::Route(route)) => {
2117 let mut pending = vec![route];
2118 while let Some(current) = pending.pop() {
2119 if reaches(¤t.fragment_id)
2120 || reaches(¤t.content_fragment_id)
2121 || reaches(¤t.pending_component)
2122 || reaches(¤t.error_component)
2123 {
2124 return true;
2125 }
2126 pending.extend(current.children.iter());
2127 }
2128 false
2129 }
2130 _ => false,
2131 }
2132 }
2133
2134 #[cold]
2135 #[inline(never)]
2136 fn transitively_nested_boundary_error(owner: &str, name: &str) -> ParserError {
2137 Diagnostic::error(format!(
2138 "boundary \"{name}\" transitively contains another boundary"
2139 ))
2140 .code(codes::NESTED_BOUNDARY)
2141 .component(owner)
2142 .element("boundary")
2143 .snippet(format!("name=\"{name}\""))
2144 .help("remove the inner declaration or move it outside the enclosing boundary, including declarations reached through components and runtime branches")
2145 .into()
2146 }
2147
2148 #[cold]
2151 #[inline(never)]
2152 fn repeat_reachable_boundary_error(violation: &RepeatBoundaryViolation) -> ParserError {
2153 let RepeatBoundaryViolation {
2154 repeat_owner,
2155 each,
2156 boundary_owner,
2157 boundary_name,
2158 } = violation;
2159 Diagnostic::error(format!(
2160 "boundary \"{boundary_name}\" in {boundary_owner} is reachable from a <for> repeat body"
2161 ))
2162 .code(codes::BOUNDARY_IN_REPEAT)
2163 .component(repeat_owner)
2164 .element("for")
2165 .snippet(format!("each=\"{each}\""))
2166 .help(
2167 "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
2168 whole <for> in one <boundary> to render the list as a single independently paced \
2169 region — this includes declarations reached through components, runtime branches, \
2170 routes, and outlets",
2171 )
2172 .into()
2173 }
2174
2175 #[must_use]
2186 pub fn module_entry_srcs(&self) -> &[String] {
2187 &self.module_entry_srcs
2188 }
2189
2190 pub fn component_shadow_dom_usage(&self) -> impl Iterator<Item = (&str, bool)> {
2195 self.component_dom_analyses
2196 .iter()
2197 .map(|(tag_name, analysis)| (tag_name.as_str(), analysis.uses_shadow_dom))
2198 }
2199
2200 pub fn component_work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
2202 self.component_registry.work_policies()
2203 }
2204
2205 pub fn take_plugin_artifacts(&mut self) -> Result<ParserPluginArtifacts> {
2212 self.plugin
2213 .take()
2214 .map_or(Ok(ParserPluginArtifacts::None), |plugin| plugin.finish())
2215 }
2216
2217 #[must_use]
2221 pub fn take_tokens(&mut self) -> Vec<String> {
2222 self.token_analysis().protocol_tokens
2223 }
2224
2225 #[must_use]
2232 pub fn token_analysis(&self) -> CssTokenAnalysis {
2233 let (fallback_chains, token_sites) = self.collect_unresolved_fallback_chains();
2234 let mut protocol_token_set = HashSet::new();
2235 for chain in &fallback_chains {
2236 for token in &chain.tokens {
2237 protocol_token_set.insert(token.as_str());
2238 }
2239 }
2240 let mut protocol_tokens: Vec<String> =
2241 protocol_token_set.into_iter().map(str::to_owned).collect();
2242 protocol_tokens.sort();
2243 CssTokenAnalysis {
2244 protocol_tokens,
2245 fallback_chains,
2246 token_sites,
2247 }
2248 }
2249
2250 fn collect_unresolved_fallback_chains(
2251 &self,
2252 ) -> (Vec<CssFallbackChain>, HashMap<String, TokenSite>) {
2253 let mut out = UnresolvedTokens::default();
2254 let mut available_counts: HashMap<&str, usize> = HashMap::new();
2255 let mut ops: Vec<TokenGraphOp<'_>> = Vec::with_capacity(self.token_roots.len());
2256 let mut active_fragments = HashSet::new();
2257 let mut visits =
2258 (!self.named_for_fragment_ids.is_empty()).then(scoped_visits::ScopedVisits::default);
2259 for root in self.token_roots.iter().rev() {
2260 ops.push(TokenGraphOp::EnterFragment(root.as_str()));
2261 }
2262
2263 while let Some(op) = ops.pop() {
2264 match op {
2265 TokenGraphOp::EnterFragment(fragment_id) => {
2266 if let Some(visits) = &mut visits {
2267 if active_fragments.contains(fragment_id)
2268 || !visits.insert(fragment_id, available_counts.keys().copied())
2269 {
2270 continue;
2271 }
2272 active_fragments.insert(fragment_id);
2273 ops.push(TokenGraphOp::ExitFragment(fragment_id));
2274 }
2275 self.enter_token_fragment(
2276 fragment_id,
2277 &mut available_counts,
2278 &mut out,
2279 &mut ops,
2280 );
2281 }
2282 TokenGraphOp::ExitFragment(fragment_id) => {
2283 active_fragments.remove(fragment_id);
2284 }
2285 TokenGraphOp::EnterComponent(tag_name) => {
2286 self.enter_token_component(tag_name, &mut available_counts, &mut out, &mut ops);
2287 }
2288 TokenGraphOp::EnterRoute(route) => {
2289 self.enter_token_route(route, &mut available_counts, &mut out, &mut ops);
2290 }
2291 TokenGraphOp::ExitDefinitions(definitions) => {
2292 remove_token_definitions(definitions, &mut available_counts);
2293 }
2294 }
2295 }
2296
2297 (out.chains, out.sites)
2298 }
2299
2300 fn enter_token_fragment<'a>(
2301 &'a self,
2302 fragment_id: &'a str,
2303 available_counts: &mut HashMap<&'a str, usize>,
2304 out: &mut UnresolvedTokens,
2305 ops: &mut Vec<TokenGraphOp<'a>>,
2306 ) {
2307 if let Some(css) = self.fragment_css_tokens.get(fragment_id) {
2308 add_token_definitions(&css.definitions, available_counts);
2309 record_unresolved_requirements(
2313 &css.fallback_chains,
2314 available_counts,
2315 fragment_id,
2316 None,
2317 out,
2318 );
2319 ops.push(TokenGraphOp::ExitDefinitions(&css.definitions));
2320 }
2321
2322 let Some(fragments) = self.fragment_records.get(fragment_id) else {
2323 return;
2324 };
2325 for fragment in fragments.fragments.iter().rev() {
2326 match fragment.fragment.as_ref() {
2327 Some(web_ui_fragment::Fragment::Component(component)) => {
2328 ops.push(TokenGraphOp::EnterComponent(component.fragment_id.as_str()));
2329 }
2330 Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
2331 ops.push(TokenGraphOp::EnterFragment(for_loop.fragment_id.as_str()));
2332 }
2333 Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
2334 ops.push(TokenGraphOp::EnterFragment(if_cond.fragment_id.as_str()));
2335 }
2336 Some(web_ui_fragment::Fragment::Route(route)) => {
2337 ops.push(TokenGraphOp::EnterRoute(route));
2338 }
2339 _ => {}
2340 }
2341 }
2342 }
2343
2344 fn enter_token_component<'a>(
2345 &'a self,
2346 tag_name: &'a str,
2347 available_counts: &mut HashMap<&'a str, usize>,
2348 out: &mut UnresolvedTokens,
2349 ops: &mut Vec<TokenGraphOp<'a>>,
2350 ) {
2351 let Some(component) = self.component_registry.get(tag_name) else {
2352 return;
2353 };
2354 add_token_definitions(&component.css_definitions, available_counts);
2355 record_unresolved_requirements(
2356 &component.css_fallback_chains,
2357 available_counts,
2358 &css_owner_label(tag_name),
2359 self.component_registry.diagnostic_css_content(tag_name),
2360 out,
2361 );
2362 ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2363 ops.push(TokenGraphOp::EnterFragment(tag_name));
2364 }
2365
2366 fn enter_token_route<'a>(
2367 &'a self,
2368 route: &'a WebUiFragmentRoute,
2369 available_counts: &mut HashMap<&'a str, usize>,
2370 out: &mut UnresolvedTokens,
2371 ops: &mut Vec<TokenGraphOp<'a>>,
2372 ) {
2373 if !route.content_fragment_id.is_empty() {
2374 ops.push(TokenGraphOp::EnterFragment(
2375 route.content_fragment_id.as_str(),
2376 ));
2377 }
2378 let Some(component) = self.component_registry.get(&route.fragment_id) else {
2379 return;
2380 };
2381 add_token_definitions(&component.css_definitions, available_counts);
2382 record_unresolved_requirements(
2383 &component.css_fallback_chains,
2384 available_counts,
2385 &css_owner_label(&route.fragment_id),
2386 self.component_registry
2387 .diagnostic_css_content(&route.fragment_id),
2388 out,
2389 );
2390 ops.push(TokenGraphOp::ExitDefinitions(&component.css_definitions));
2391 if !route.error_component.is_empty() {
2392 ops.push(TokenGraphOp::EnterComponent(route.error_component.as_str()));
2393 }
2394 if !route.pending_component.is_empty() {
2395 ops.push(TokenGraphOp::EnterComponent(
2396 route.pending_component.as_str(),
2397 ));
2398 }
2399 for child in route.children.iter().rev() {
2400 ops.push(TokenGraphOp::EnterRoute(child));
2401 }
2402 ops.push(TokenGraphOp::EnterFragment(route.fragment_id.as_str()));
2403 }
2404
2405 fn record_fragment_css_tokens(
2406 &mut self,
2407 definitions: HashSet<String>,
2408 fallback_chains: Vec<CssFallbackChain>,
2409 ) {
2410 let css = self
2411 .fragment_css_tokens
2412 .entry(self.current_fragment_id.clone())
2413 .or_default();
2414 css.definitions.extend(definitions);
2415 css.definitions.sort();
2416 css.definitions.dedup();
2417 css.fallback_chains.extend(fallback_chains);
2418 }
2419
2420 pub fn parse(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2422 let fragment_key = fragment_id.to_string();
2423 let is_token_root = self.in_progress_fragments.is_empty();
2424 if is_token_root {
2425 self.module_entry_srcs.clear();
2426 }
2427 self.boundary_names_by_owner.remove(&fragment_key);
2428 let previous_fragment_id =
2434 std::mem::replace(&mut self.current_fragment_id, fragment_key.clone());
2435 if !self.in_progress_fragments.insert(fragment_key.clone()) {
2436 let err = self
2437 .authoring_error(
2438 codes::RECURSIVE_TEMPLATE,
2439 format!("recursive template reference while parsing <{fragment_id}>"),
2440 )
2441 .help(
2442 "move the recursive usage behind runtime data, or split the component graph \
2443 so templates do not reference themselves at build time",
2444 )
2445 .into();
2446 self.current_fragment_id = previous_fragment_id;
2447 return Err(err);
2448 }
2449 if is_token_root && !self.token_roots.contains(&fragment_key) {
2450 self.token_roots.push(fragment_key.clone());
2451 }
2452
2453 let previous_in_boundary = std::mem::replace(&mut self.in_boundary, false);
2459 let previous_boundary_ancestor_depth = self.boundary_ancestor_depth;
2460 if previous_in_boundary {
2461 self.boundary_ancestor_depth += 1;
2462 }
2463 let previous_body_depth = std::mem::replace(&mut self.body_depth, 0);
2464 let previous_for_depth = std::mem::replace(&mut self.for_depth, 0);
2465 let previous_route_depth = std::mem::replace(&mut self.route_depth, 0);
2466 let previous_foster_depth = std::mem::replace(&mut self.foster_context_depth, 0);
2467 let previous_parent_scope = self.boundary_parent_scope.take();
2468 let previous_named_for_loops = std::mem::take(&mut self.named_for_loops);
2469 let owner_index = self.loop_owner_indices.len() + 1;
2470 self.loop_owner_indices
2471 .entry(fragment_key.clone())
2472 .or_insert(owner_index);
2473
2474 let mut result = self.parse_inner(fragment_id, html_content);
2475 if is_token_root && result.is_ok() {
2476 result = self.finalize_boundary_metadata();
2477 if result.is_ok() {
2478 self.finalize_outlet_warnings();
2479 }
2480 }
2481
2482 self.in_boundary = previous_in_boundary;
2483 self.boundary_ancestor_depth = previous_boundary_ancestor_depth;
2484 self.body_depth = previous_body_depth;
2485 self.for_depth = previous_for_depth;
2486 self.route_depth = previous_route_depth;
2487 self.foster_context_depth = previous_foster_depth;
2488 self.boundary_parent_scope = previous_parent_scope;
2489 self.named_for_loops = previous_named_for_loops;
2490 self.in_progress_fragments.remove(&fragment_key);
2491 self.current_fragment_id = previous_fragment_id;
2492 result
2493 }
2494
2495 fn parse_inner(&mut self, fragment_id: &str, html_content: &str) -> Result<()> {
2496 if html_content.len() > MAX_TEMPLATE_BYTES {
2497 return Err(ParserError::Html(format!(
2498 "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.",
2499 html_content.len()
2500 )));
2501 }
2502
2503 self.raw_buffer.clear();
2505 if let Some(ref mut plugin) = self.plugin {
2506 plugin.begin_fragment(FragmentContext { id: fragment_id });
2507 }
2508
2509 let mut entry_fragment: Vec<WebUIFragment> = Vec::new();
2510 self.parse_range(html_content, 0..html_content.len(), &mut entry_fragment, 0)?;
2511 self.named_for_loops.validate(fragment_id, html_content)?;
2512
2513 self.flush_raw_buffer(&mut entry_fragment);
2514
2515 self.fragment_records.insert(
2516 fragment_id.to_string(),
2517 FragmentList {
2518 fragments: entry_fragment,
2519 contains_boundary: false,
2520 },
2521 );
2522
2523 Ok(())
2524 }
2525
2526 fn add_raw_fragment(&mut self, content: &str) {
2528 if !content.is_empty() {
2529 self.raw_buffer.push_str(content);
2530 }
2531 }
2532
2533 fn add_for_fragment(
2535 &mut self,
2536 item: String,
2537 collection: String,
2538 fragment_id: String,
2539 fragments: &mut Vec<WebUIFragment>,
2540 ) {
2541 self.flush_raw_buffer(fragments);
2542 fragments.push(WebUIFragment::for_loop(item, collection, fragment_id));
2543 }
2544
2545 fn add_if_fragment(
2547 &mut self,
2548 condition: ConditionExpr,
2549 fragment_id: String,
2550 fragments: &mut Vec<WebUIFragment>,
2551 ) {
2552 self.flush_raw_buffer(fragments);
2553 fragments.push(WebUIFragment::if_cond(condition, fragment_id));
2554 }
2555
2556 fn add_fragment(&mut self, fragment: WebUIFragment, fragments: &mut Vec<WebUIFragment>) {
2558 self.flush_raw_buffer(fragments);
2559 fragments.push(fragment);
2560 }
2561
2562 fn flush_raw_buffer(&mut self, fragments: &mut Vec<WebUIFragment>) {
2564 if !self.raw_buffer.is_empty() {
2565 fragments.push(WebUIFragment::raw(std::mem::take(&mut self.raw_buffer)));
2566 }
2567 }
2568
2569 fn should_emit_text_content(content: &str) -> bool {
2575 if content.is_empty() {
2576 return false;
2577 }
2578
2579 if !content.trim().is_empty() {
2580 return true;
2581 }
2582
2583 content.chars().all(char::is_whitespace)
2584 && !content.contains('\n')
2585 && !content.contains('\r')
2586 }
2587
2588 fn parse_range(
2589 &mut self,
2590 source: &str,
2591 range: Range<usize>,
2592 fragments: &mut Vec<WebUIFragment>,
2593 depth: usize,
2594 ) -> Result<()> {
2595 let mut ops = vec![ParseOp::Parse { range, depth }];
2596 let mut outlet_seen = false;
2597
2598 while let Some(op) = ops.pop() {
2599 match op {
2600 ParseOp::Parse { range, depth } => {
2601 if depth > MAX_TEMPLATE_DEPTH {
2602 return Err(self
2603 .html_error(
2604 codes::EXCESSIVE_NESTING,
2605 format!(
2606 "template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"
2607 ),
2608 source,
2609 range.start,
2610 )
2611 .help(
2612 "split deeply nested markup into components, or reduce generated nesting",
2613 )
2614 .into());
2615 }
2616
2617 let end = range.end;
2618 let mut index = range.start;
2619 while index < end {
2620 let remaining = &source[index..end];
2621 if remaining.starts_with("<!--") {
2622 let Some(close) = html::find_comment_close(remaining) else {
2623 return Err(self
2624 .html_error(
2625 codes::UNTERMINATED_HTML_COMMENT,
2626 "unterminated HTML comment",
2627 source,
2628 index,
2629 )
2630 .help("close the comment with `-->`")
2631 .into());
2632 };
2633 index += close;
2634 continue;
2635 }
2636
2637 if remaining.starts_with("<!") {
2638 let Some(close) = html::find_declaration_close(remaining) else {
2639 return Err(self
2640 .html_error(
2641 codes::UNTERMINATED_HTML_DECLARATION,
2642 "unterminated HTML declaration",
2643 source,
2644 index,
2645 )
2646 .help("close the declaration with `>`")
2647 .into());
2648 };
2649 self.add_raw_fragment(&remaining[..close]);
2650 index += close;
2651 continue;
2652 }
2653
2654 if remaining.starts_with('<') {
2655 let Some(tag) = html::parse_tag(remaining) else {
2656 return Err(self
2657 .html_error(
2658 codes::MALFORMED_HTML_TAG,
2659 "malformed HTML tag",
2660 source,
2661 index,
2662 )
2663 .help(
2664 "close the tag with `>`, or escape a literal `<` as `<`",
2665 )
2666 .into());
2667 };
2668 if tag.closing {
2669 return Err(self
2670 .html_error(
2671 codes::UNEXPECTED_CLOSING_TAG,
2672 format!("unexpected closing tag </{}>", tag.name),
2673 source,
2674 index,
2675 )
2676 .help("remove it, or add the matching opening tag before it")
2677 .into());
2678 }
2679
2680 let content_start = index + tag.close + 1;
2681 let (content_end, close_end) = if tag.self_closing
2682 || html::is_void_element(tag.name)
2683 {
2684 (content_start, content_start)
2685 } else if let Some((close_start, close_end)) =
2686 html::find_matching_end(remaining, tag.name, tag.close + 1)
2687 {
2688 (index + close_start, index + close_end)
2689 } else {
2690 return Err(self
2691 .html_error(
2692 codes::UNCLOSED_HTML_TAG,
2693 format!("unclosed <{}> tag", tag.name),
2694 source,
2695 index,
2696 )
2697 .help(format!(
2698 "add the matching </{}> closing tag, or make the element self-closing",
2699 tag.name
2700 ))
2701 .into());
2702 };
2703
2704 let element = Element {
2705 source,
2706 start: index,
2707 tag,
2708 content_start,
2709 content_end,
2710 close_end,
2711 };
2712 if close_end < end {
2713 ops.push(ParseOp::Parse {
2714 range: close_end..end,
2715 depth,
2716 });
2717 }
2718
2719 match element.name() {
2720 "for" => {
2721 self.enter_for_directive(&element, fragments, depth, &mut ops)?;
2722 }
2723 "if" => {
2724 self.enter_if_directive(&element, fragments, depth, &mut ops)?;
2725 }
2726 "route" => {
2727 self.process_route_directive(&element, fragments, depth)?;
2728 }
2729 "outlet" => {
2730 self.flush_raw_buffer(fragments);
2731 if outlet_seen {
2732 self.warnings.push(self.multiple_outlets_warning(&element));
2733 }
2734 outlet_seen = true;
2735 fragments.push(WebUIFragment::outlet());
2736 }
2737 "boundary" => {
2738 self.enter_boundary_directive(
2739 &element, fragments, depth, &mut ops,
2740 )?;
2741 }
2742 name if name.eq_ignore_ascii_case("body") => {
2743 self.enter_body_element(&element, fragments, depth, &mut ops)?;
2744 }
2745 name if name.eq_ignore_ascii_case("head") => {
2746 self.enter_head_element(&element, fragments, depth, &mut ops)?;
2747 }
2748 name if name.eq_ignore_ascii_case("webui-hydrate") => {
2749 return Err(self.authored_webui_hydrate_error(&element));
2750 }
2751 name if name.eq_ignore_ascii_case("style") => {
2752 self.process_style_element(&element, fragments)?;
2753 }
2754 _ if self.component_registry.contains(element.name()) => {
2755 self.enter_component_directive(
2756 &element, fragments, depth, &mut ops,
2757 )?;
2758 }
2759 _ => {
2760 self.check_component_typo(&element)?;
2761 self.enter_regular_element(
2762 &element, fragments, depth, &mut ops,
2763 )?;
2764 }
2765 }
2766
2767 break;
2768 }
2769
2770 let next = remaining.find('<').unwrap_or(remaining.len());
2771 let text_end = if next == 0 {
2772 remaining
2773 .chars()
2774 .next()
2775 .map_or(remaining.len(), char::len_utf8)
2776 } else {
2777 next
2778 };
2779 self.process_text(&remaining[..text_end], fragments)?;
2780 index += text_end;
2781 }
2782 }
2783 ParseOp::EmitClose(name) => {
2784 self.add_raw_fragment("</");
2785 self.add_raw_fragment(name);
2786 self.add_raw_fragment(">");
2787 }
2788 ParseOp::EndComponent(name) => {
2789 self.add_raw_fragment("</");
2790 self.add_raw_fragment(name);
2791 self.add_raw_fragment(">");
2792 self.flush_raw_buffer(fragments);
2793 fragments.push(structural_signal(format!("streaming_span_end:{name}")));
2794 }
2795 ParseOp::EndHead(name) => {
2796 self.flush_raw_buffer(fragments);
2797 fragments.push(structural_signal("head_end"));
2798 self.add_raw_fragment("</");
2799 self.add_raw_fragment(name);
2800 self.add_raw_fragment(">");
2801 }
2802 ParseOp::EndBody(name) => {
2803 self.flush_raw_buffer(fragments);
2804 fragments.push(structural_signal("body_end"));
2805 self.add_raw_fragment("</");
2806 self.add_raw_fragment(name);
2807 self.add_raw_fragment(">");
2808 self.body_depth = self.body_depth.saturating_sub(1);
2809 }
2810 ParseOp::CompleteBoundary { boundary } => {
2811 self.flush_raw_buffer(fragments);
2812 fragments.push(WebUIFragment::boundary_end(boundary.declaration_id));
2813 self.in_boundary = false;
2814 }
2815 ParseOp::RestoreFosterDepth(previous) => {
2816 self.foster_context_depth = previous;
2817 }
2818 ParseOp::RestoreBoundaryParentScope(previous) => {
2819 self.boundary_parent_scope = previous;
2820 }
2821 ParseOp::CompleteFor {
2822 parent,
2823 item,
2824 collection,
2825 fragment_id,
2826 named_definition,
2827 previous_for_depth,
2828 } => {
2829 self.for_depth = previous_for_depth;
2830 self.flush_raw_buffer(fragments);
2831 let for_fragment = std::mem::take(fragments);
2832 *fragments = parent.fragments;
2833 self.raw_buffer = parent.raw_buffer;
2834
2835 if !for_fragment.is_empty() || named_definition {
2836 self.fragment_records.insert(
2837 fragment_id.clone(),
2838 FragmentList {
2839 fragments: for_fragment,
2840 contains_boundary: false,
2841 },
2842 );
2843 } else {
2844 continue;
2845 }
2846
2847 self.add_for_fragment(item, collection, fragment_id, fragments);
2848 }
2849 ParseOp::CompleteIf {
2850 parent,
2851 condition,
2852 fragment_id,
2853 } => {
2854 self.flush_raw_buffer(fragments);
2855 let if_fragment = std::mem::take(fragments);
2856 *fragments = parent.fragments;
2857 self.raw_buffer = parent.raw_buffer;
2858
2859 self.fragment_records.insert(
2860 fragment_id.clone(),
2861 FragmentList {
2862 fragments: if_fragment,
2863 contains_boundary: false,
2864 },
2865 );
2866 self.add_if_fragment(condition, fragment_id, fragments);
2867 }
2868 }
2869 }
2870
2871 Ok(())
2872 }
2873
2874 fn enter_regular_element<'a>(
2875 &mut self,
2876 element: &Element<'a>,
2877 fragments: &mut Vec<WebUIFragment>,
2878 depth: usize,
2879 ops: &mut Vec<ParseOp<'a>>,
2880 ) -> Result<()> {
2881 if !self.in_boundary
2882 && self.boundary_ancestor_depth == 0
2883 && element.name().eq_ignore_ascii_case("script")
2884 {
2885 self.record_module_entry(element);
2886 }
2887 self.add_raw_fragment("<");
2888 self.add_raw_fragment(element.name());
2889
2890 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2891 if let Some(ref mut p) = self.plugin {
2892 if let Some(data) = p.finish_opening_tag(ElementStartContext {
2893 tag_name: element.name(),
2894 binding_count,
2895 }) {
2896 self.add_fragment(WebUIFragment::plugin(data), fragments);
2897 }
2898 }
2899
2900 if element.self_closing() {
2901 self.add_raw_fragment("/>");
2902 return Ok(());
2903 }
2904
2905 self.add_raw_fragment(">");
2906 if element.name().eq_ignore_ascii_case("template")
2907 && element
2908 .attrs()
2909 .find(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
2910 .and_then(|attr| attr.value)
2911 .is_some_and(|mode| mode.eq_ignore_ascii_case("open"))
2912 && self
2913 .component_dom_analyses
2914 .get(&self.current_fragment_id)
2915 .is_some_and(|analysis| analysis.uses_shadow_dom)
2916 {
2917 self.flush_raw_buffer(fragments);
2922 fragments.push(structural_signal(format!(
2923 "shadow_styles:{}",
2924 self.current_fragment_id
2925 )));
2926 }
2927 if !element.is_void() {
2928 if element.close_end() > element.content_end() {
2929 ops.push(ParseOp::EmitClose(element.name()));
2930 }
2931 self.enter_foster_context(element.name(), ops);
2932 self.enter_boundary_parent_scope(element, ops);
2933 ops.push(ParseOp::Parse {
2934 range: element.inner(),
2935 depth: depth + 1,
2936 });
2937 }
2938 Ok(())
2939 }
2940
2941 fn record_module_entry(&mut self, element: &Element<'_>) {
2952 let Some(src) = element.attr("src") else {
2953 return;
2954 };
2955 if src.is_empty() || src.contains("{{") {
2956 return;
2957 }
2958 if element
2959 .attrs()
2960 .find(|attr| attr.name.eq_ignore_ascii_case("fetchpriority"))
2961 .and_then(|attr| attr.value)
2962 .is_some_and(|priority| priority.eq_ignore_ascii_case("low"))
2963 {
2964 return;
2965 }
2966 if !element
2967 .attr("type")
2968 .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module"))
2969 {
2970 return;
2971 }
2972 if self.module_entry_srcs.iter().any(|seen| seen == src) {
2975 return;
2976 }
2977 self.module_entry_srcs.push(src.to_string());
2978 }
2979
2980 fn enter_head_element<'a>(
2981 &mut self,
2982 element: &Element<'a>,
2983 fragments: &mut Vec<WebUIFragment>,
2984 depth: usize,
2985 ops: &mut Vec<ParseOp<'a>>,
2986 ) -> Result<()> {
2987 if element.name() == "head" {
2988 self.add_raw_fragment("<head>");
2992 } else {
2993 self.add_raw_fragment("<");
2997 self.add_raw_fragment(element.name());
2998 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
2999 if let Some(ref mut p) = self.plugin {
3000 if let Some(data) = p.finish_opening_tag(ElementStartContext {
3001 tag_name: element.name(),
3002 binding_count,
3003 }) {
3004 self.add_fragment(WebUIFragment::plugin(data), fragments);
3005 }
3006 }
3007 self.add_raw_fragment(">");
3008 }
3009 self.flush_raw_buffer(fragments);
3010 fragments.push(structural_signal("head_start"));
3016 ops.push(ParseOp::EndHead(element.name()));
3017 ops.push(ParseOp::Parse {
3018 range: element.inner(),
3019 depth: depth + 1,
3020 });
3021 Ok(())
3022 }
3023
3024 fn enter_body_element<'a>(
3025 &mut self,
3026 element: &Element<'a>,
3027 fragments: &mut Vec<WebUIFragment>,
3028 depth: usize,
3029 ops: &mut Vec<ParseOp<'a>>,
3030 ) -> Result<()> {
3031 self.add_raw_fragment("<");
3032 self.add_raw_fragment(element.name());
3033 let binding_count = self.process_tag_attributes(element.attrs(), fragments, false)?;
3034 if let Some(ref mut p) = self.plugin {
3035 if let Some(data) = p.finish_opening_tag(ElementStartContext {
3036 tag_name: element.name(),
3037 binding_count,
3038 }) {
3039 self.add_fragment(WebUIFragment::plugin(data), fragments);
3040 }
3041 }
3042 self.add_raw_fragment(">");
3043 self.flush_raw_buffer(fragments);
3044 fragments.push(structural_signal("body_start"));
3045 self.body_depth += 1;
3046 ops.push(ParseOp::EndBody(element.name()));
3047 ops.push(ParseOp::Parse {
3048 range: element.inner(),
3049 depth: depth + 1,
3050 });
3051 Ok(())
3052 }
3053
3054 #[cold]
3061 #[inline(never)]
3062 fn authoring_error(&self, code: &'static str, title: impl Into<String>) -> Diagnostic {
3063 let diag = Diagnostic::error(title).code(code);
3064 if self.current_fragment_id.is_empty() {
3065 diag
3066 } else {
3067 diag.component(self.current_fragment_id.clone())
3068 }
3069 }
3070
3071 #[cold]
3075 #[inline(never)]
3076 fn authoring_error_at(
3077 &self,
3078 code: &'static str,
3079 title: impl Into<String>,
3080 element: &Element<'_>,
3081 ) -> Diagnostic {
3082 self.authoring_error(code, title)
3083 .at_offset(element.source(), element.start)
3084 }
3085
3086 #[cold]
3090 #[inline(never)]
3091 fn html_error(
3092 &self,
3093 code: &'static str,
3094 title: impl Into<String>,
3095 source: &str,
3096 offset: usize,
3097 ) -> Diagnostic {
3098 self.authoring_error(code, title).at_offset(source, offset)
3099 }
3100
3101 #[cold]
3103 #[inline(never)]
3104 fn multiple_outlets_warning(&self, element: &Element<'_>) -> Diagnostic {
3105 outlet_warnings::multiple_outlets_warning(&self.current_fragment_id)
3106 .at_offset(element.source(), element.start)
3107 }
3108
3109 #[cold]
3115 #[inline(never)]
3116 fn unknown_component_help(&self, name: &str) -> String {
3117 match suggest::closest_match(name, self.component_registry.names()) {
3118 Some(suggestion) => format!(
3119 "did you mean <{suggestion}>? otherwise register the component by adding a \
3120 matching .html file"
3121 ),
3122 None => "register the component (add a matching .html file) or check the tag name \
3123 for a typo"
3124 .to_string(),
3125 }
3126 }
3127
3128 fn suggest_component(&self, tag: &str) -> Option<&str> {
3137 let (prefix, _) = tag.split_once('-')?;
3138 let same_namespace = self
3139 .component_registry
3140 .names()
3141 .filter(|name| name.split_once('-').map(|(p, _)| p) == Some(prefix));
3142 suggest::closest_match(tag, same_namespace)
3143 }
3144
3145 fn check_component_typo(&self, element: &Element<'_>) -> Result<()> {
3150 if let Some(suggestion) = self.suggest_component(element.name()) {
3151 return Err(self
3152 .authoring_error_at(
3153 codes::UNKNOWN_COMPONENT,
3154 format!("unknown component <{}>", element.name()),
3155 element,
3156 )
3157 .help(format!(
3158 "did you mean <{suggestion}>? otherwise register <{}> by adding a matching \
3159 .html file",
3160 element.name()
3161 ))
3162 .into());
3163 }
3164 Ok(())
3165 }
3166
3167 #[cold]
3171 #[inline(never)]
3172 fn attr_typo_suggestion<'a>(element: &Element<'a>, expected: &str) -> Option<&'a str> {
3173 suggest::closest_match(expected, element.attrs().map(|attr| attr.name))
3174 .filter(|&name| name != expected)
3175 }
3176
3177 #[cold]
3183 #[inline(never)]
3184 fn css_diagnostic(&self, err: ParserError) -> ParserError {
3185 match err {
3186 ParserError::Css(message) => self.authoring_error(codes::INVALID_CSS, message).into(),
3187 other => other,
3188 }
3189 }
3190
3191 #[cold]
3193 #[inline(never)]
3194 fn for_each_missing_error(&self, element: &Element<'_>) -> ParserError {
3195 let diag = self
3196 .authoring_error_at(
3197 codes::MISSING_FOR_EACH,
3198 "missing each attribute on <for>",
3199 element,
3200 )
3201 .element("for");
3202 match Self::attr_typo_suggestion(element, "each") {
3203 Some(typo) => diag.help(format!(
3204 "found `{typo}` \u{2014} did you mean each=\"item in collection\"?"
3205 )),
3206 None => diag.help("add each=\"item in collection\", e.g. <for each=\"todo in todos\">"),
3207 }
3208 .into()
3209 }
3210
3211 fn enter_for_directive<'a>(
3212 &mut self,
3213 element: &Element<'a>,
3214 fragments: &mut Vec<WebUIFragment>,
3215 depth: usize,
3216 ops: &mut Vec<ParseOp<'a>>,
3217 ) -> Result<()> {
3218 let each = element
3219 .attr("each")
3220 .ok_or_else(|| self.for_each_missing_error(element))?;
3221 let (item, collection) = parse_for_each(&self.current_fragment_id, each)
3222 .map_err(|error| locate_for_error(error, element))?;
3223
3224 let named_id = validate_for_id(&self.current_fragment_id, &element.tag)
3225 .map_err(|error| locate_for_error(error, element))?;
3226 let fragment_id = if let Some(id) = named_id {
3227 self.named_for_loops
3228 .register(
3229 &self.current_fragment_id,
3230 id,
3231 item,
3232 (!element.self_closing(), element.start),
3233 )
3234 .map_err(|error| locate_for_error(error, element))?;
3235 let scoped_id = format!(
3236 "{id}-{}",
3237 self.loop_owner_indices[&self.current_fragment_id]
3238 );
3239 if !self.named_for_fragment_ids.contains(&scoped_id)
3240 && (self.id_counter.has_generated(&scoped_id)
3241 || self.fragment_records.contains_key(&scoped_id)
3242 || self.component_registry.contains(&scoped_id)
3243 || self.in_progress_fragments.contains(&scoped_id))
3244 {
3245 return Err(self.for_id_collision_error(element, &scoped_id));
3246 }
3247 self.named_for_fragment_ids.insert(scoped_id.clone());
3248 scoped_id
3249 } else {
3250 self.next_fragment_id("for")
3251 };
3252 if named_id.is_some() && element.self_closing() {
3253 self.add_for_fragment(
3254 item.to_string(),
3255 collection.to_string(),
3256 fragment_id,
3257 fragments,
3258 );
3259 return Ok(());
3260 }
3261 let parent = ParseContext {
3262 fragments: std::mem::take(fragments),
3263 raw_buffer: std::mem::take(&mut self.raw_buffer),
3264 };
3265
3266 let previous_for_depth = self.for_depth;
3267 self.for_depth += 1;
3268 ops.push(ParseOp::CompleteFor {
3269 parent,
3270 item: item.to_string(),
3271 collection: collection.to_string(),
3272 fragment_id,
3273 named_definition: named_id.is_some(),
3274 previous_for_depth,
3275 });
3276 ops.push(ParseOp::Parse {
3277 range: element.inner(),
3278 depth: depth + 1,
3279 });
3280 Ok(())
3281 }
3282
3283 fn next_fragment_id(&mut self, prefix: &str) -> String {
3284 loop {
3285 let id = self.id_counter.next_id(prefix);
3286 if !self.named_for_fragment_ids.contains(&id) {
3287 return id;
3288 }
3289 }
3290 }
3291
3292 #[cold]
3293 #[inline(never)]
3294 fn for_id_collision_error(&self, element: &Element<'_>, id: &str) -> ParserError {
3295 self.authoring_error_at(
3296 codes::INVALID_FOR_ID,
3297 "named <for> id conflicts with an existing fragment",
3298 element,
3299 )
3300 .element("for")
3301 .help(format!(
3302 "choose a different id; the fragment name '{id}' is already in use"
3303 ))
3304 .into()
3305 }
3306
3307 #[cold]
3310 #[inline(never)]
3311 fn if_condition_missing_error(&self, element: &Element<'_>) -> ParserError {
3312 let diag = self
3313 .authoring_error_at(
3314 codes::MISSING_IF_CONDITION,
3315 "missing condition attribute on <if>",
3316 element,
3317 )
3318 .element("if");
3319 match Self::attr_typo_suggestion(element, "condition") {
3320 Some(typo) => diag.help(format!(
3321 "found `{typo}` \u{2014} did you mean condition=\"expression\"?"
3322 )),
3323 None => diag.help("add condition=\"expression\", e.g. <if condition=\"isActive\">"),
3324 }
3325 .into()
3326 }
3327
3328 #[cold]
3330 #[inline(never)]
3331 fn if_condition_invalid_error(&self, element: &Element<'_>, condition: &str) -> ParserError {
3332 self.authoring_error_at(
3333 codes::INVALID_IF_CONDITION,
3334 "invalid <if> condition expression",
3335 element,
3336 )
3337 .element("if")
3338 .snippet(format!("condition=\"{condition}\""))
3339 .help("use a simple expression like \"isActive\", \"count > 0\", or \"!hidden\"")
3340 .into()
3341 }
3342
3343 fn enter_if_directive<'a>(
3344 &mut self,
3345 element: &Element<'a>,
3346 fragments: &mut Vec<WebUIFragment>,
3347 depth: usize,
3348 ops: &mut Vec<ParseOp<'a>>,
3349 ) -> Result<()> {
3350 let condition_str = element
3351 .attr("condition")
3352 .ok_or_else(|| self.if_condition_missing_error(element))?;
3353
3354 let condition = self
3355 .condition_parser
3356 .parse(strip_condition_braces(condition_str))
3357 .map_err(|_| self.if_condition_invalid_error(element, condition_str))?;
3358
3359 self.flush_raw_buffer(fragments);
3360 let parent = ParseContext {
3361 fragments: std::mem::take(fragments),
3362 raw_buffer: std::mem::take(&mut self.raw_buffer),
3363 };
3364 let fragment_id = self.next_fragment_id("if");
3365
3366 ops.push(ParseOp::CompleteIf {
3367 parent,
3368 condition,
3369 fragment_id,
3370 });
3371 ops.push(ParseOp::Parse {
3372 range: element.inner(),
3373 depth: depth + 1,
3374 });
3375 Ok(())
3376 }
3377
3378 #[inline]
3386 fn enter_foster_context<'a>(&mut self, name: &str, ops: &mut Vec<ParseOp<'a>>) {
3387 match foster_context_of(name) {
3388 FosterContext::Transparent => {}
3389 FosterContext::Hostile => {
3390 ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3391 self.foster_context_depth += 1;
3392 }
3393 FosterContext::Barrier => {
3394 if self.foster_context_depth != 0 {
3395 ops.push(ParseOp::RestoreFosterDepth(self.foster_context_depth));
3396 self.foster_context_depth = 0;
3397 }
3398 }
3399 }
3400 }
3401
3402 #[inline]
3403 fn enter_boundary_parent_scope<'a>(
3404 &mut self,
3405 element: &Element<'a>,
3406 ops: &mut Vec<ParseOp<'a>>,
3407 ) {
3408 if element.name().eq_ignore_ascii_case("template") && element.has_attr("shadowrootmode") {
3409 return;
3410 }
3411 let Some(scope) = boundary_parent_scope(element.name()) else {
3412 return;
3413 };
3414 ops.push(ParseOp::RestoreBoundaryParentScope(
3415 self.boundary_parent_scope,
3416 ));
3417 self.boundary_parent_scope = Some(scope);
3418 }
3419
3420 #[inline]
3421 fn enter_component_content_scope<'a>(&mut self, ops: &mut Vec<ParseOp<'a>>) {
3422 ops.push(ParseOp::RestoreBoundaryParentScope(
3423 self.boundary_parent_scope,
3424 ));
3425 self.boundary_parent_scope = Some("component host content");
3426 }
3427
3428 fn enter_boundary_directive<'a>(
3454 &mut self,
3455 element: &Element<'a>,
3456 fragments: &mut Vec<WebUIFragment>,
3457 depth: usize,
3458 ops: &mut Vec<ParseOp<'a>>,
3459 ) -> Result<()> {
3460 if let Some(scope) = self.boundary_parent_scope {
3461 return Err(self.boundary_scope_error(element, scope));
3462 }
3463 if self.in_boundary {
3464 return Err(self.nested_boundary_error(element));
3465 }
3466 if self.for_depth != 0 {
3467 return Err(self.boundary_in_repeat_error(element));
3468 }
3469 let owner_fragment_id = self.current_fragment_id.clone();
3470 let owner_is_component = self.component_registry.contains(&owner_fragment_id);
3471 if !owner_is_component && self.body_depth == 0 && self.route_depth == 0 {
3472 return Err(self.boundary_outside_body_error(element));
3473 }
3474 if self.foster_context_depth != 0 {
3475 return Err(self.boundary_in_foster_context_error(element));
3476 }
3477
3478 let name = self.validate_boundary_name(element)?;
3479 let inserted = self
3480 .boundary_names_by_owner
3481 .entry(owner_fragment_id.clone())
3482 .or_default()
3483 .insert(name.clone());
3484 if !inserted {
3485 return Err(self.duplicate_boundary_name_error(element, &name));
3486 }
3487 let key = self.validate_boundary_key(element)?;
3488 let declaration_id = self.allocate_boundary_declaration_id(element)?;
3489 self.in_boundary = true;
3490 self.add_fragment(
3491 WebUIFragment {
3492 fragment: Some(Fragment::Boundary(WebUiFragmentBoundary {
3493 declaration_id,
3494 owner_fragment_id,
3495 name,
3496 key,
3497 may_repeat: false,
3498 phase: BoundaryPhase::Start as i32,
3499 })),
3500 },
3501 fragments,
3502 );
3503
3504 ops.push(ParseOp::CompleteBoundary {
3505 boundary: PendingBoundary { declaration_id },
3506 });
3507 ops.push(ParseOp::Parse {
3508 range: element.inner(),
3509 depth: depth + 1,
3510 });
3511 Ok(())
3512 }
3513
3514 fn validate_boundary_name(&self, element: &Element<'_>) -> Result<String> {
3517 let name = element.attr("name").unwrap_or_default();
3518 if name.trim().is_empty() {
3519 return Err(self.missing_boundary_name_error(element));
3520 }
3521 if name.contains("{{") {
3522 return Err(self.invalid_boundary_name_error(element, name));
3523 }
3524 Ok(name.to_string())
3525 }
3526
3527 fn validate_boundary_key(&self, element: &Element<'_>) -> Result<Option<String>> {
3528 if !element.has_attr("key") {
3529 return Ok(None);
3530 }
3531 let Some(raw) = element.attr("key") else {
3532 return Err(self.invalid_boundary_key_error(element, ""));
3533 };
3534 let trimmed = raw.trim();
3535 let braced = trimmed.starts_with("{{") || trimmed.ends_with("}}");
3536 let valid = if braced {
3537 trimmed
3538 .strip_prefix("{{")
3539 .and_then(|value| value.strip_suffix("}}"))
3540 .filter(|value| !value.starts_with('{') && !value.ends_with('}'))
3541 .is_some_and(|value| !value.trim().is_empty())
3542 } else {
3543 !trimmed.is_empty()
3544 };
3545 if !valid {
3546 return Err(self.invalid_boundary_key_error(element, raw));
3547 }
3548 Ok(Some(raw.to_string()))
3549 }
3550
3551 fn allocate_boundary_declaration_id(&mut self, element: &Element<'_>) -> Result<u32> {
3552 let declaration_id = self.next_boundary_declaration_id;
3553 let Some(next) = declaration_id.checked_add(1) else {
3554 return Err(self.too_many_boundaries_error(element));
3555 };
3556 self.next_boundary_declaration_id = next;
3557 Ok(declaration_id)
3558 }
3559
3560 #[cold]
3563 #[inline(never)]
3564 fn missing_boundary_name_error(&self, element: &Element<'_>) -> ParserError {
3565 self.authoring_error_at(
3566 codes::MISSING_BOUNDARY_NAME,
3567 "missing name attribute on <boundary>",
3568 element,
3569 )
3570 .element("boundary")
3571 .help("add a unique static name, e.g. <boundary name=\"counter-ready\">")
3572 .into()
3573 }
3574
3575 #[cold]
3578 #[inline(never)]
3579 fn invalid_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3580 self.authoring_error_at(
3581 codes::INVALID_BOUNDARY_NAME,
3582 "invalid name attribute on <boundary>",
3583 element,
3584 )
3585 .element("boundary")
3586 .snippet(format!("name=\"{name}\""))
3587 .help("boundary names must be a static string literal, e.g. name=\"counter-ready\"")
3588 .into()
3589 }
3590
3591 #[cold]
3593 #[inline(never)]
3594 fn duplicate_boundary_name_error(&self, element: &Element<'_>, name: &str) -> ParserError {
3595 self.authoring_error_at(
3596 codes::DUPLICATE_BOUNDARY_NAME,
3597 format!("duplicate <boundary> name \"{name}\""),
3598 element,
3599 )
3600 .element("boundary")
3601 .help("boundary names must be unique within their entry or component template; rename one of the declarations")
3602 .into()
3603 }
3604
3605 #[cold]
3606 #[inline(never)]
3607 fn missing_boundary_key_error(owner: &str, name: &str) -> ParserError {
3608 Diagnostic::error(format!(
3609 "boundary \"{name}\" renders more than once and has no key"
3610 ))
3611 .code(codes::MISSING_BOUNDARY_KEY)
3612 .component(owner)
3613 .element("boundary")
3614 .snippet(format!("name=\"{name}\""))
3615 .help(
3616 "the owning template is rendered from more than one callsite, so every occurrence \
3617 needs a distinct key, e.g. key=\"{{sectionId}}\" — or render the declaration from a \
3618 single callsite",
3619 )
3620 .into()
3621 }
3622
3623 #[cold]
3625 #[inline(never)]
3626 fn boundary_in_repeat_error(&self, element: &Element<'_>) -> ParserError {
3627 let name = element.attr("name").unwrap_or_default();
3628 self.authoring_error_at(
3629 codes::BOUNDARY_IN_REPEAT,
3630 "<boundary> is not valid inside a <for> repeat",
3631 element,
3632 )
3633 .element("boundary")
3634 .snippet(format!("name=\"{name}\""))
3635 .help(
3636 "a repeat iteration cannot suspend: move the boundary outside the <for>, or wrap the \
3637 whole <for> in one <boundary> to render the list as a single independently paced \
3638 region",
3639 )
3640 .into()
3641 }
3642
3643 #[cold]
3644 #[inline(never)]
3645 fn invalid_boundary_key_error(&self, element: &Element<'_>, key: &str) -> ParserError {
3646 self.authoring_error_at(
3647 codes::INVALID_BOUNDARY_KEY,
3648 "invalid key expression on <boundary>",
3649 element,
3650 )
3651 .element("boundary")
3652 .snippet(format!("key=\"{key}\""))
3653 .help("use a non-empty expression such as key=\"item.id\" or key=\"{{item.id}}\"")
3654 .into()
3655 }
3656
3657 #[cold]
3658 #[inline(never)]
3659 fn too_many_boundaries_error(&self, element: &Element<'_>) -> ParserError {
3660 self.authoring_error_at(
3661 codes::TOO_MANY_BOUNDARIES,
3662 "too many boundary declarations",
3663 element,
3664 )
3665 .element("boundary")
3666 .help("split this build into smaller protocols so each declaration has a unique u32 ID")
3667 .into()
3668 }
3669
3670 #[cold]
3673 #[inline(never)]
3674 fn nested_boundary_error(&self, element: &Element<'_>) -> ParserError {
3675 self.authoring_error_at(
3676 codes::NESTED_BOUNDARY,
3677 "nested <boundary> is not allowed",
3678 element,
3679 )
3680 .element("boundary")
3681 .help("boundaries cannot be nested; close the enclosing <boundary> before opening another")
3682 .into()
3683 }
3684
3685 #[cold]
3688 #[inline(never)]
3689 fn boundary_scope_error(&self, element: &Element<'_>, scope: &str) -> ParserError {
3690 self.authoring_error_at(
3691 codes::BOUNDARY_CROSSES_SCOPE,
3692 format!("<boundary> cannot appear inside {scope}"),
3693 element,
3694 )
3695 .element("boundary")
3696 .help("move the <boundary> outside this raw, inert, or component-host content scope")
3697 .into()
3698 }
3699
3700 #[cold]
3702 #[inline(never)]
3703 fn boundary_outside_body_error(&self, element: &Element<'_>) -> ParserError {
3704 self.authoring_error_at(
3705 codes::BOUNDARY_OUTSIDE_BODY,
3706 "<boundary> must appear inside <body>",
3707 element,
3708 )
3709 .element("boundary")
3710 .help("move the <boundary> between the opening <body> and its matching </body>")
3711 .into()
3712 }
3713
3714 #[cold]
3722 #[inline(never)]
3723 fn boundary_in_foster_context_error(&self, element: &Element<'_>) -> ParserError {
3724 self.authoring_error_at(
3725 codes::BOUNDARY_IN_FOSTER_CONTEXT,
3726 "<boundary> cannot appear inside a table or select context",
3727 element,
3728 )
3729 .element("boundary")
3730 .help(
3731 "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>",
3732 )
3733 .into()
3734 }
3735
3736 #[cold]
3737 #[inline(never)]
3738 fn route_boundary_placement_error(&self, element: &Element<'_>) -> ParserError {
3739 self.authoring_error_at(
3740 codes::INVALID_ROUTE_BOUNDARY_PLACEMENT,
3741 "route boundary must be a direct <route> child",
3742 element,
3743 )
3744 .element("boundary")
3745 .help("move the <boundary> directly under <route>, or place it in the route component template")
3746 .into()
3747 }
3748
3749 #[cold]
3753 #[inline(never)]
3754 fn authored_webui_hydrate_error(&self, element: &Element<'_>) -> ParserError {
3755 self.authoring_error_at(
3756 codes::AUTHORED_WEBUI_HYDRATE,
3757 "<webui-hydrate> is reserved for the generated runtime sentinel",
3758 element,
3759 )
3760 .element("webui-hydrate")
3761 .help("remove <webui-hydrate>; it is emitted only by the compiler/handler, never authored")
3762 .into()
3763 }
3764
3765 fn enter_component_directive<'a>(
3766 &mut self,
3767 element: &Element<'a>,
3768 fragments: &mut Vec<WebUIFragment>,
3769 depth: usize,
3770 ops: &mut Vec<ParseOp<'a>>,
3771 ) -> Result<()> {
3772 let needs_template_build = !self.fragment_records.contains_key(element.name());
3773 let cached_dom_analysis = self.component_dom_analyses.get(element.name()).copied();
3774 let has_plugin_artifact = self
3775 .component_registry
3776 .component_artifact_source(element.name())
3777 .is_some();
3778 let authored_html = if needs_template_build || cached_dom_analysis.is_none() {
3779 self.component_registry
3780 .component_authored_source(element.name())
3781 } else {
3782 None
3783 };
3784 let (dom_analysis, template_source) = {
3785 let component = self.component_registry.get(element.name()).ok_or_else(|| {
3786 self.authoring_error_at(
3787 codes::UNKNOWN_COMPONENT,
3788 format!("unknown component <{}>", element.name()),
3789 element,
3790 )
3791 .help(self.unknown_component_help(element.name()))
3792 })?;
3793 (
3794 cached_dom_analysis.map_or_else(
3795 || {
3796 let fast_html = (has_plugin_artifact
3797 && self.options.dom_strategy == DomStrategy::Shadow)
3798 .then(|| {
3799 Self::append_shadow_mode_if_missing(component.html_content.clone())
3800 });
3801 analyze_component_dom_with_diagnostic_source(
3802 element.name(),
3803 fast_html.as_deref().unwrap_or(&component.html_content),
3804 self.options.dom_strategy,
3805 authored_html.as_deref(),
3806 )
3807 },
3808 Ok,
3809 )?,
3810 needs_template_build.then(|| {
3811 (
3812 component.html_content.clone(),
3813 component.css_content.clone(),
3814 )
3815 }),
3816 )
3817 };
3818 if cached_dom_analysis.is_none() {
3819 self.component_dom_analyses
3820 .insert(element.name().to_string(), dom_analysis);
3821 }
3822
3823 self.flush_raw_buffer(fragments);
3824 fragments.push(structural_signal(format!(
3825 "streaming_span_start:{}",
3826 element.name()
3827 )));
3828 self.add_raw_fragment("<");
3829 self.add_raw_fragment(element.name());
3830 if self
3831 .component_registry
3832 .render_policy(element.name())
3833 .is_some_and(ComponentRenderPolicy::is_interaction_hydration)
3834 {
3835 self.add_raw_fragment(" data-webui-interaction");
3836 }
3837
3838 let binding_count = self.process_tag_attributes(element.attrs(), fragments, true)?;
3839 if let Some(ref mut p) = self.plugin {
3840 if let Some(data) = p.finish_opening_tag(ElementStartContext {
3841 tag_name: element.name(),
3842 binding_count,
3843 }) {
3844 self.add_fragment(WebUIFragment::plugin(data), fragments);
3845 }
3846 }
3847
3848 self.flush_raw_buffer(fragments);
3855 fragments.push(structural_signal(format!(
3856 "streaming_root:{}",
3857 element.name()
3858 )));
3859
3860 if element.self_closing() {
3861 self.add_raw_fragment("/>");
3862 } else {
3863 self.add_raw_fragment(">");
3864 }
3865
3866 self.flush_raw_buffer(fragments);
3867
3868 if let Some((html_content, css_content)) = template_source {
3869 let artifact_html = self
3870 .component_registry
3871 .component_artifact_source(element.name())
3872 .map(str::to_string);
3873 let built = self.build_component_templates(ComponentTemplateInput {
3874 tag_name: element.name(),
3875 html: &html_content,
3876 artifact_html: artifact_html.as_deref(),
3877 authored_html: authored_html.as_deref(),
3878 css_content: css_content.as_deref(),
3879 artifact_needed: self.plugin.is_some(),
3880 })?;
3881
3882 if let Some(ref mut p) = self.plugin {
3883 let component_data = self
3884 .component_registry
3885 .get(element.name())
3886 .ok_or_else(|| {
3887 ParserError::NotFound(format!(
3888 "component <{}> disappeared during CSS compilation",
3889 element.name()
3890 ))
3891 })?
3892 .clone();
3893 p.component_built(built.plugin_context(&component_data))?;
3894 }
3895
3896 self.parse(element.name(), &built.ssr)?;
3897 }
3898
3899 fragments.push(WebUIFragment::component(element.name().to_string()));
3900
3901 if element.self_closing() {
3902 fragments.push(structural_signal(format!(
3903 "streaming_span_end:{}",
3904 element.name()
3905 )));
3906 } else {
3907 ops.push(ParseOp::EndComponent(element.name()));
3908 self.enter_component_content_scope(ops);
3909 ops.push(ParseOp::Parse {
3910 range: element.inner(),
3911 depth: depth + 1,
3912 });
3913 }
3914
3915 Ok(())
3916 }
3917
3918 fn analyze_component_dom(
3919 &mut self,
3920 tag_name: &str,
3921 source: &str,
3922 authored_source: Option<&str>,
3923 ) -> Result<ComponentDomAnalysis> {
3924 if let Some(analysis) = self.component_dom_analyses.get(tag_name) {
3925 return Ok(*analysis);
3926 }
3927 let analysis = analyze_component_dom_with_diagnostic_source(
3928 tag_name,
3929 source,
3930 self.options.dom_strategy,
3931 authored_source,
3932 )?;
3933 self.component_dom_analyses
3934 .insert(tag_name.to_string(), analysis);
3935 Ok(analysis)
3936 }
3937
3938 fn process_text(&mut self, content: &str, fragments: &mut Vec<WebUIFragment>) -> Result<()> {
3939 if !Self::should_emit_text_content(content) {
3940 return Ok(());
3941 }
3942
3943 let parsed_fragments = self.handlebars_parser.parse(content)?;
3944 for fragment in parsed_fragments {
3945 if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() {
3946 if Self::should_emit_text_content(&raw.value) {
3947 self.add_raw_fragment(&raw.value);
3948 }
3949 } else if let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() {
3950 if signal.raw && is_html_text_only_scope(self.boundary_parent_scope) {
3951 self.add_fragment(
3952 WebUIFragment::raw_text_signal(signal.value.as_str(), true),
3953 fragments,
3954 );
3955 } else {
3956 self.add_fragment(fragment, fragments);
3957 }
3958 } else {
3959 self.add_fragment(fragment, fragments);
3960 }
3961 }
3962 Ok(())
3963 }
3964
3965 fn process_style_content(
3966 &mut self,
3967 css: &str,
3968 comments: &[crate::css_parser::CssComment],
3969 fragments: &mut Vec<WebUIFragment>,
3970 ) {
3971 let mut last_end = 0usize;
3972
3973 for comment in comments {
3974 if comment.start_byte < last_end {
3975 if comment.end_byte > last_end {
3976 last_end = comment.end_byte;
3977 }
3978 continue;
3979 }
3980
3981 self.add_raw_fragment(&css[last_end..comment.start_byte]);
3982 let comment_text = &css[comment.start_byte..comment.end_byte];
3983 if let Some(fragment) = self.css_signal_comment_fragment(comment_text) {
3984 self.add_fragment(fragment, fragments);
3985 } else if comment.preserve {
3986 self.add_raw_fragment(comment_text);
3987 }
3988 last_end = comment.end_byte;
3989 }
3990
3991 self.add_raw_fragment(&css[last_end..]);
3992 }
3993
3994 fn css_signal_comment_fragment(&self, comment: &str) -> Option<WebUIFragment> {
3995 let signal = comment_policy::parse_css_signal_comment(comment)?;
3996 Some(WebUIFragment::raw_text_signal(signal.path, signal.raw))
3997 }
3998
3999 fn extract_single_handlebars(value: &str) -> Option<&str> {
4002 let trimmed = value.trim();
4003 if trimmed.starts_with("{{") && trimmed.ends_with("}}") && !trimmed.starts_with("{{{") {
4004 let inner = trimmed[2..trimmed.len() - 2].trim();
4005 if !inner.contains("{{") && !inner.is_empty() {
4007 return Some(inner);
4008 }
4009 }
4010 None
4011 }
4012
4013 fn contains_handlebars(value: &str) -> bool {
4015 value.contains("{{")
4016 }
4017
4018 fn process_tag_attributes(
4019 &mut self,
4020 attrs: Attrs<'_>,
4021 fragments: &mut Vec<WebUIFragment>,
4022 is_component: bool,
4023 ) -> Result<u32> {
4024 let mut first_dynamic_emitted = false;
4025 let mut binding_count: u32 = 0;
4026 for attr in attrs {
4027 let attr_name = attr.name;
4028 let attr_value = attr.value;
4029 let is_property_binding = attr_name.starts_with(':')
4030 && attr_value
4031 .and_then(Self::extract_single_handlebars)
4032 .is_some();
4033
4034 if !is_property_binding {
4038 if let Some(ref mut p) = self.plugin {
4039 match p.process_attribute(AttributeContext { name: attr_name }) {
4040 AttributeAction::Keep => {}
4041 AttributeAction::Skip => continue,
4042 AttributeAction::SkipAndCountBinding => {
4043 binding_count += 1;
4044 continue;
4045 }
4046 }
4047 }
4048 }
4049
4050 if let Some(bool_name) = attr_name.strip_prefix('?') {
4051 if is_component {
4052 if let Some(condition) = self.parse_boolean_condition(attr_value) {
4053 let frag = Self::maybe_mark_attr_start(
4054 WebUIFragment::attribute_boolean(bool_name, condition),
4055 &mut first_dynamic_emitted,
4056 );
4057 self.add_fragment(frag, fragments);
4058 binding_count += 1;
4059 }
4060 } else if self.process_boolean_attribute(bool_name, attr_value, fragments)? {
4061 binding_count += 1;
4062 }
4063 } else if let Some(prop_name) = attr_name.strip_prefix(':') {
4064 if !is_component {
4065 return Err(ParserError::Parse(format!(
4066 ":{prop_name} complex binding is only allowed on custom elements. \
4067 Use {prop_name}=\"{{{{expr}}}}\" for native HTML elements."
4068 )));
4069 }
4070 if Self::is_blocked_complex_property(prop_name) {
4071 return Err(ParserError::Parse(format!(
4072 ":{prop_name} is not allowed as a complex attribute binding \
4073 because it enables arbitrary HTML injection. \
4074 Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
4075 )));
4076 }
4077 if let Some(val) = attr_value {
4078 if let Some(signal_name) = Self::extract_single_handlebars(val) {
4079 let frag = Self::maybe_mark_attr_start(
4080 WebUIFragment::attribute_complex(attr_name, signal_name),
4081 &mut first_dynamic_emitted,
4082 );
4083 self.add_fragment(frag, fragments);
4084 binding_count += 1;
4085 }
4086 }
4087 } else if is_component && Self::is_skipped_attribute(attr_name) {
4088 if let Some(val) = attr_value {
4089 if let Some(signal_name) = Self::extract_single_handlebars(val) {
4090 let frag = WebUIFragment {
4091 fragment: Some(web_ui_fragment::Fragment::Attribute(
4092 WebUIFragmentAttribute {
4093 name: attr_name.to_string(),
4094 value: signal_name.to_string(),
4095 attr_skip: true,
4096 ..Default::default()
4097 },
4098 )),
4099 };
4100 self.add_fragment(frag, fragments);
4101 binding_count += 1;
4102 } else if Self::contains_handlebars(val) {
4103 let template_id = self.next_fragment_id("attr");
4104 let parsed = self.parse_attribute_template(val)?;
4105 self.fragment_records.insert(
4106 template_id.clone(),
4107 FragmentList {
4108 fragments: parsed,
4109 contains_boundary: false,
4110 },
4111 );
4112 let frag = WebUIFragment {
4113 fragment: Some(web_ui_fragment::Fragment::Attribute(
4114 WebUIFragmentAttribute {
4115 name: attr_name.to_string(),
4116 template: template_id,
4117 attr_skip: true,
4118 ..Default::default()
4119 },
4120 )),
4121 };
4122 self.add_fragment(frag, fragments);
4123 binding_count += 1;
4124 } else {
4125 let frag = WebUIFragment {
4126 fragment: Some(web_ui_fragment::Fragment::Attribute(
4127 WebUIFragmentAttribute {
4128 name: attr_name.to_string(),
4129 value: html_escape::decode_html_entities(val).into_owned(),
4130 raw_value: true,
4131 attr_skip: true,
4132 ..Default::default()
4133 },
4134 )),
4135 };
4136 self.add_fragment(frag, fragments);
4137 }
4138 } else {
4139 self.add_raw_fragment(" ");
4140 self.add_raw_fragment(attr_name);
4141 }
4142 } else if let Some(val) = attr_value {
4143 if Self::contains_handlebars(val) {
4144 if is_component {
4145 if let Some(signal_name) = Self::extract_single_handlebars(val) {
4146 let frag = Self::maybe_mark_attr_start(
4147 WebUIFragment::attribute(attr_name, signal_name),
4148 &mut first_dynamic_emitted,
4149 );
4150 self.add_fragment(frag, fragments);
4151 binding_count += 1;
4152 } else {
4153 let template_id = self.next_fragment_id("attr");
4154 let parsed = self.parse_attribute_template(val)?;
4155 self.fragment_records.insert(
4156 template_id.clone(),
4157 FragmentList {
4158 fragments: parsed,
4159 contains_boundary: false,
4160 },
4161 );
4162 let frag = Self::maybe_mark_attr_start(
4163 WebUIFragment::attribute_template(attr_name, template_id),
4164 &mut first_dynamic_emitted,
4165 );
4166 self.add_fragment(frag, fragments);
4167 binding_count += 1;
4168 }
4169 } else {
4170 self.process_dynamic_attribute(attr_name, val, fragments)?;
4171 binding_count += 1;
4172 }
4173 } else if is_component {
4174 let frag = Self::maybe_mark_attr_start(
4175 WebUIFragment {
4176 fragment: Some(web_ui_fragment::Fragment::Attribute(
4177 WebUIFragmentAttribute {
4178 name: attr_name.to_string(),
4179 value: html_escape::decode_html_entities(val).into_owned(),
4180 raw_value: true,
4181 ..Default::default()
4182 },
4183 )),
4184 },
4185 &mut first_dynamic_emitted,
4186 );
4187 self.add_fragment(frag, fragments);
4188 } else {
4189 self.add_raw_fragment(" ");
4190 self.add_raw_fragment(attr.raw);
4191 }
4192 } else if is_component {
4193 let fragment = Self::maybe_mark_attr_start(
4194 WebUIFragment::attribute_boolean(attr_name, ConditionExpr::identifier("true")),
4195 &mut first_dynamic_emitted,
4196 );
4197 self.add_fragment(fragment, fragments);
4198 } else {
4199 self.add_raw_fragment(" ");
4200 self.add_raw_fragment(attr_name);
4201 }
4202 }
4203 Ok(binding_count)
4204 }
4205
4206 fn parse_attribute_template(&mut self, value: &str) -> Result<Vec<WebUIFragment>> {
4207 let mut fragments = self.handlebars_parser.parse(value)?;
4208 for fragment in &mut fragments {
4209 if let Some(web_ui_fragment::Fragment::Raw(raw)) = &mut fragment.fragment {
4210 if let std::borrow::Cow::Owned(decoded) =
4211 html_escape::decode_html_entities(&raw.value)
4212 {
4213 raw.value = decoded;
4214 }
4215 }
4216 }
4217 Ok(fragments)
4218 }
4219
4220 fn maybe_mark_attr_start(
4223 mut frag: WebUIFragment,
4224 first_dynamic_emitted: &mut bool,
4225 ) -> WebUIFragment {
4226 if !*first_dynamic_emitted {
4227 if let Some(web_ui_fragment::Fragment::Attribute(ref mut a)) = frag.fragment {
4228 a.attr_start = true;
4229 }
4230 *first_dynamic_emitted = true;
4231 }
4232 frag
4233 }
4234
4235 fn parse_boolean_condition(&self, value: Option<&str>) -> Option<ConditionExpr> {
4238 if let Some(val) = value {
4239 if let Some(expr_str) = Self::extract_single_handlebars(val) {
4240 return Some(
4241 self.condition_parser
4242 .parse(expr_str)
4243 .unwrap_or_else(|_| ConditionExpr::identifier(expr_str)),
4244 );
4245 }
4246 }
4247
4248 None
4249 }
4250
4251 fn process_boolean_attribute(
4252 &mut self,
4253 name: &str,
4254 value: Option<&str>,
4255 fragments: &mut Vec<WebUIFragment>,
4256 ) -> Result<bool> {
4257 if let Some(condition) = self.parse_boolean_condition(value) {
4258 self.add_fragment(WebUIFragment::attribute_boolean(name, condition), fragments);
4259 return Ok(true);
4260 }
4261 Ok(false)
4263 }
4264
4265 fn process_dynamic_attribute(
4267 &mut self,
4268 name: &str,
4269 value: &str,
4270 fragments: &mut Vec<WebUIFragment>,
4271 ) -> Result<()> {
4272 if let Some(signal_name) = Self::extract_single_handlebars(value) {
4273 self.add_fragment(WebUIFragment::attribute(name, signal_name), fragments);
4275 } else {
4276 let template_id = self.next_fragment_id("attr");
4278 let parsed = self.handlebars_parser.parse(value)?;
4279
4280 self.fragment_records.insert(
4281 template_id.clone(),
4282 FragmentList {
4283 fragments: parsed,
4284 contains_boundary: false,
4285 },
4286 );
4287
4288 self.add_fragment(
4289 WebUIFragment::attribute_template(name, template_id),
4290 fragments,
4291 );
4292 }
4293 Ok(())
4294 }
4295
4296 fn process_style_element(
4297 &mut self,
4298 element: &Element<'_>,
4299 fragments: &mut Vec<WebUIFragment>,
4300 ) -> Result<()> {
4301 self.add_raw_fragment(element.opening());
4302 let inner = element.inner();
4303 let style_content = &element.source()[inner.start..inner.end];
4304 let (defs, requirements, comments) = self
4305 .css_parser
4306 .extract_definitions_requirements_and_comments(
4307 style_content,
4308 self.options.legal_comments,
4309 )
4310 .map_err(|e| self.css_diagnostic(e))?;
4311 self.record_fragment_css_tokens(defs, requirements);
4312 self.process_style_content(style_content, &comments, fragments);
4313 if element.close_end() > element.content_end() {
4314 self.add_raw_fragment("</");
4318 self.add_raw_fragment(element.name());
4319 self.add_raw_fragment(">");
4320 }
4321 Ok(())
4322 }
4323
4324 fn process_route_directive(
4330 &mut self,
4331 element: &Element<'_>,
4332 fragments: &mut Vec<WebUIFragment>,
4333 depth: usize,
4334 ) -> Result<()> {
4335 let attrs = Self::route_attrs_from_element(element);
4336 let path = attrs.path.clone();
4337 let component = attrs.component.clone();
4338 route_parser::validate_attributes(&attrs)?;
4339
4340 let route_params: std::collections::HashSet<String> =
4341 route_parser::extract_params(&path)?.into_iter().collect();
4342 if !attrs.cache_tags.is_empty() {
4343 route_parser::validate_tag_placeholders(
4344 &attrs.cache_tags,
4345 &route_params,
4346 "cache-tags",
4347 &path,
4348 )?;
4349 }
4350 if !attrs.invalidates.is_empty() {
4351 route_parser::validate_tag_placeholders(
4352 &attrs.invalidates,
4353 &route_params,
4354 "invalidates",
4355 &path,
4356 )?;
4357 }
4358
4359 if !attrs.pending_component.is_empty() {
4360 self.ensure_route_component_parsed(&attrs.pending_component)?;
4361 }
4362 if !attrs.error_component.is_empty() {
4363 self.ensure_route_component_parsed(&attrs.error_component)?;
4364 }
4365 self.ensure_route_component_parsed(&component)?;
4366 self.flush_raw_buffer(fragments);
4367 let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4368
4369 let mut all_params = std::collections::HashSet::new();
4370 all_params.extend(route_params);
4371 self.validate_route_nesting_depth(element.source(), element.inner(), 1)?;
4372 let children =
4373 self.parse_child_routes(element.source(), element.inner(), &all_params, 1)?;
4374
4375 let mut route_fragment =
4376 route_parser::build_route_fragment(&attrs, component.clone(), children);
4377 route_fragment.content_fragment_id = content_fragment_id;
4378 fragments.push(WebUIFragment::route_from(route_fragment));
4379
4380 Ok(())
4381 }
4382
4383 fn parse_route_boundary_content(
4384 &mut self,
4385 route: &Element<'_>,
4386 depth: usize,
4387 ) -> Result<String> {
4388 let inner = route.inner();
4389 if !route.source()[inner.clone()].contains("<boundary") {
4390 return Ok(String::new());
4391 }
4392 let saved_buffer = std::mem::take(&mut self.raw_buffer);
4393 let previous_route_depth = self.route_depth;
4394 self.route_depth += 1;
4395 let mut content = Vec::new();
4396 let parse_result: Result<()> = (|| {
4397 for event in Walker::new_range(route.source(), inner.start, inner.end) {
4398 if let Event::Element(element) = event {
4399 if element.name() == "boundary" {
4400 self.parse_range(
4401 element.source(),
4402 element.start..element.close_end(),
4403 &mut content,
4404 depth + 1,
4405 )?;
4406 }
4407 }
4408 }
4409 self.flush_raw_buffer(&mut content);
4410 Ok(())
4411 })();
4412 self.route_depth = previous_route_depth;
4413 self.raw_buffer = saved_buffer;
4414 parse_result?;
4415
4416 if content.is_empty() {
4417 return Ok(String::new());
4418 }
4419 let fragment_id = self.next_fragment_id("route-content");
4420 self.fragment_records.insert(
4421 fragment_id.clone(),
4422 FragmentList {
4423 fragments: content,
4424 contains_boundary: false,
4425 },
4426 );
4427 Ok(fragment_id)
4428 }
4429
4430 fn parse_child_routes(
4437 &mut self,
4438 source: &str,
4439 range: Range<usize>,
4440 ancestor_params: &std::collections::HashSet<String>,
4441 depth: usize,
4442 ) -> Result<Vec<WebUiFragmentRoute>> {
4443 if depth > MAX_TEMPLATE_DEPTH {
4444 return Err(self
4445 .html_error(
4446 codes::EXCESSIVE_NESTING,
4447 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4448 source,
4449 range.start,
4450 )
4451 .help("flatten deeply nested <route> trees before building")
4452 .into());
4453 }
4454
4455 let mut children = Vec::new();
4456 for event in Walker::new_range(source, range.start, range.end) {
4457 match event {
4458 Event::Element(element) => {
4459 self.validate_closed_element(&element)?;
4460 self.reject_boundary_or_hydrate_in_route(&element, true)?;
4461 if element.name() == "route" {
4462 children.push(self.parse_route_as_fragment(
4463 &element,
4464 ancestor_params,
4465 depth,
4466 )?);
4467 } else if element.name().eq_ignore_ascii_case("style") {
4468 self.validate_style_element(&element)?;
4469 } else if !element.self_closing() && !element.is_void() {
4470 self.validate_ignored_route_html(element.source(), element.inner(), depth)?;
4471 }
4472 }
4473 Event::Text(text) => {
4474 if text.contains('<') {
4475 return Err(self
4476 .authoring_error(
4477 codes::MALFORMED_HTML_TAG,
4478 "malformed HTML tag in <route>",
4479 )
4480 .help("close the tag with `>`, or escape a literal `<` as `<`")
4481 .into());
4482 }
4483 }
4484 Event::Comment(comment_range) => {
4485 self.validate_comment_range(source, comment_range)?;
4486 }
4487 Event::Declaration(declaration_range) => {
4488 self.validate_declaration_range(source, declaration_range)?;
4489 }
4490 Event::ClosingTag(closing_range) => {
4491 return Err(self.unexpected_closing_tag_error(source, closing_range));
4492 }
4493 }
4494 }
4495
4496 Ok(children)
4497 }
4498
4499 fn validate_route_nesting_depth(
4500 &self,
4501 source: &str,
4502 range: Range<usize>,
4503 depth: usize,
4504 ) -> Result<()> {
4505 let mut stack = vec![(range, depth)];
4506 while let Some((range, depth)) = stack.pop() {
4507 if depth > MAX_TEMPLATE_DEPTH {
4508 return Err(self
4509 .html_error(
4510 codes::EXCESSIVE_NESTING,
4511 format!("route nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4512 source,
4513 range.start,
4514 )
4515 .help("flatten deeply nested <route> trees before building")
4516 .into());
4517 }
4518
4519 for event in Walker::new_range(source, range.start, range.end) {
4520 if let Event::Element(element) = event {
4521 self.validate_closed_element(&element)?;
4522 if element.name() == "route" && !element.self_closing() {
4523 stack.push((element.inner(), depth + 1));
4524 }
4525 }
4526 }
4527 }
4528 Ok(())
4529 }
4530
4531 fn validate_ignored_route_html(
4532 &mut self,
4533 source: &str,
4534 range: Range<usize>,
4535 depth: usize,
4536 ) -> Result<()> {
4537 let mut stack = vec![(range, depth)];
4538 while let Some((range, depth)) = stack.pop() {
4539 if depth > MAX_TEMPLATE_DEPTH {
4540 return Err(self
4541 .html_error(
4542 codes::EXCESSIVE_NESTING,
4543 format!("template nesting exceeds the {MAX_TEMPLATE_DEPTH}-level limit"),
4544 source,
4545 range.start,
4546 )
4547 .help("split deeply nested markup into components, or reduce generated nesting")
4548 .into());
4549 }
4550
4551 for event in Walker::new_range(source, range.start, range.end) {
4552 match event {
4553 Event::Element(element) => {
4554 self.validate_closed_element(&element)?;
4555 self.reject_boundary_or_hydrate_in_route(&element, false)?;
4556 if element.name().eq_ignore_ascii_case("style") {
4557 self.validate_style_element(&element)?;
4558 } else if !element.self_closing() && !element.is_void() {
4559 stack.push((element.inner(), depth + 1));
4560 }
4561 }
4562 Event::Text(text) => {
4563 if text.contains('<') {
4564 return Err(self
4565 .authoring_error(
4566 codes::MALFORMED_HTML_TAG,
4567 "malformed HTML tag in <route>",
4568 )
4569 .help("close the tag with `>`, or escape a literal `<` as `<`")
4570 .into());
4571 }
4572 }
4573 Event::Comment(comment_range) => {
4574 self.validate_comment_range(source, comment_range)?;
4575 }
4576 Event::Declaration(declaration_range) => {
4577 self.validate_declaration_range(source, declaration_range)?;
4578 }
4579 Event::ClosingTag(closing_range) => {
4580 return Err(self.unexpected_closing_tag_error(source, closing_range));
4581 }
4582 }
4583 }
4584 }
4585 Ok(())
4586 }
4587
4588 fn reject_boundary_or_hydrate_in_route(
4598 &self,
4599 element: &Element<'_>,
4600 allow_boundary: bool,
4601 ) -> Result<()> {
4602 if element.name() == "boundary" && !allow_boundary {
4603 Err(self.route_boundary_placement_error(element))
4604 } else if element.name().eq_ignore_ascii_case("webui-hydrate") {
4605 Err(self.authored_webui_hydrate_error(element))
4606 } else {
4607 Ok(())
4608 }
4609 }
4610
4611 fn validate_closed_element(&self, element: &Element<'_>) -> Result<()> {
4612 if !element.self_closing()
4613 && !element.is_void()
4614 && element.close_end() == element.content_end()
4615 {
4616 return Err(self
4617 .html_error(
4618 codes::UNCLOSED_HTML_TAG,
4619 format!("unclosed <{}> tag", element.name()),
4620 element.source(),
4621 element.start,
4622 )
4623 .help(format!(
4624 "add the matching </{}> closing tag, or make the element self-closing",
4625 element.name()
4626 ))
4627 .into());
4628 }
4629 Ok(())
4630 }
4631
4632 fn validate_style_element(&mut self, element: &Element<'_>) -> Result<()> {
4633 let inner = element.inner();
4634 let style_content = &element.source()[inner.start..inner.end];
4635 self.css_parser
4636 .extract_definitions_requirements_and_comments(
4637 style_content,
4638 self.options.legal_comments,
4639 )
4640 .map_err(|e| self.css_diagnostic(e))?;
4641 Ok(())
4642 }
4643
4644 fn validate_comment_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4645 let start = range.start;
4646 if source[range].ends_with("-->") {
4647 return Ok(());
4648 }
4649 Err(self
4650 .html_error(
4651 codes::UNTERMINATED_HTML_COMMENT,
4652 "unterminated HTML comment in <route>",
4653 source,
4654 start,
4655 )
4656 .help("close the comment with `-->`")
4657 .into())
4658 }
4659
4660 fn validate_declaration_range(&self, source: &str, range: Range<usize>) -> Result<()> {
4661 let start = range.start;
4662 if source[range].ends_with('>') {
4663 return Ok(());
4664 }
4665 Err(self
4666 .html_error(
4667 codes::UNTERMINATED_HTML_DECLARATION,
4668 "unterminated HTML declaration in <route>",
4669 source,
4670 start,
4671 )
4672 .help("close the declaration with `>`")
4673 .into())
4674 }
4675
4676 fn unexpected_closing_tag_error(&self, source: &str, range: Range<usize>) -> ParserError {
4677 let start = range.start;
4678 let title = match html::parse_tag(&source[range]) {
4679 Some(tag) => format!("unexpected closing tag </{}> in <route>", tag.name),
4680 None => "unexpected closing tag in <route>".to_string(),
4681 };
4682 self.html_error(codes::UNEXPECTED_CLOSING_TAG, title, source, start)
4683 .help("remove it, or add the matching opening tag before it")
4684 .into()
4685 }
4686
4687 fn route_attrs_from_element(element: &Element<'_>) -> route_parser::RouteAttributes {
4688 route_parser::RouteAttributes {
4689 path: element.attr("path").unwrap_or_default().to_string(),
4690 component: element.attr("component").unwrap_or_default().to_string(),
4691 exact: element.has_attr("exact"),
4692 query: element.attr("query").unwrap_or_default().to_string(),
4693 keep_alive: element.has_attr("keep-alive"),
4694 cache_tags: element
4695 .attr("cache-tags")
4696 .map(route_parser::parse_tag_list)
4697 .unwrap_or_default(),
4698 invalidates: element
4699 .attr("invalidates")
4700 .map(route_parser::parse_tag_list)
4701 .unwrap_or_default(),
4702 pending_component: element.attr("pending").unwrap_or_default().to_string(),
4703 error_component: element.attr("error").unwrap_or_default().to_string(),
4704 }
4705 }
4706
4707 fn parse_route_as_fragment(
4708 &mut self,
4709 element: &Element<'_>,
4710 ancestor_params: &std::collections::HashSet<String>,
4711 depth: usize,
4712 ) -> Result<WebUiFragmentRoute> {
4713 let attrs = Self::route_attrs_from_element(element);
4714 let path = attrs.path.clone();
4715 let component = attrs.component.clone();
4716
4717 route_parser::validate_attributes(&attrs)?;
4718
4719 let own_params: std::collections::HashSet<String> =
4720 route_parser::extract_params(&path)?.into_iter().collect();
4721 let mut all_params = ancestor_params.clone();
4722 all_params.extend(own_params.iter().cloned());
4723 if !attrs.cache_tags.is_empty() {
4724 route_parser::validate_tag_placeholders(
4725 &attrs.cache_tags,
4726 &all_params,
4727 "cache-tags",
4728 &path,
4729 )?;
4730 }
4731 if !attrs.invalidates.is_empty() {
4732 route_parser::validate_tag_placeholders(
4733 &attrs.invalidates,
4734 &all_params,
4735 "invalidates",
4736 &path,
4737 )?;
4738 }
4739
4740 if !attrs.pending_component.is_empty() {
4741 self.ensure_route_component_parsed(&attrs.pending_component)?;
4742 }
4743 if !attrs.error_component.is_empty() {
4744 self.ensure_route_component_parsed(&attrs.error_component)?;
4745 }
4746
4747 self.ensure_route_component_parsed(&component)?;
4748 let content_fragment_id = self.parse_route_boundary_content(element, depth)?;
4749 let children =
4750 self.parse_child_routes(element.source(), element.inner(), &all_params, depth + 1)?;
4751
4752 let mut route = route_parser::build_route_fragment(&attrs, component, children);
4753 route.content_fragment_id = content_fragment_id;
4754 Ok(route)
4755 }
4756
4757 fn ensure_route_component_parsed(&mut self, component: &str) -> Result<()> {
4759 if component.is_empty()
4760 || !self.component_registry.contains(component)
4761 || self.fragment_records.contains_key(component)
4762 {
4763 return Ok(());
4764 }
4765
4766 let component_data = self
4767 .component_registry
4768 .get(component)
4769 .ok_or_else(|| {
4770 self.authoring_error(
4771 codes::UNKNOWN_COMPONENT,
4772 format!("unknown component <{component}>"),
4773 )
4774 .help(self.unknown_component_help(component))
4775 })?
4776 .clone();
4777 let artifact_html = self
4778 .component_registry
4779 .component_artifact_source(component)
4780 .map(str::to_string);
4781 let authored_html = self.component_registry.component_authored_source(component);
4782
4783 let built = self.build_component_templates(ComponentTemplateInput {
4784 tag_name: component,
4785 html: &component_data.html_content,
4786 artifact_html: artifact_html.as_deref(),
4787 authored_html: authored_html.as_deref(),
4788 css_content: component_data.css_content.as_deref(),
4789 artifact_needed: self.plugin.is_some(),
4790 })?;
4791
4792 if let Some(ref mut p) = self.plugin {
4793 let component_data = self
4794 .component_registry
4795 .get(component)
4796 .ok_or_else(|| {
4797 ParserError::NotFound(format!(
4798 "component <{component}> disappeared during CSS compilation"
4799 ))
4800 })?
4801 .clone();
4802 p.component_built(built.plugin_context(&component_data))?;
4803 }
4804
4805 let saved_buffer = std::mem::take(&mut self.raw_buffer);
4806 self.parse(component, &built.ssr)?;
4807 self.raw_buffer = saved_buffer;
4808
4809 Ok(())
4810 }
4811
4812 const SKIPPED_ATTRIBUTES: &[&str] = &["class", "style", "role"];
4814 const SKIPPED_ATTRIBUTE_PREFIXES: &[&str] = &["data-"];
4816 const ADOPTED_STYLESHEETS_ATTR: &str = "shadowrootadoptedstylesheets";
4817
4818 fn is_skipped_attribute(name: &str) -> bool {
4819 Self::SKIPPED_ATTRIBUTES.contains(&name)
4820 || Self::SKIPPED_ATTRIBUTE_PREFIXES
4821 .iter()
4822 .any(|prefix| name.starts_with(prefix))
4823 }
4824
4825 fn is_blocked_complex_property(name: &str) -> bool {
4828 matches!(name, "innerHTML" | "outerHTML" | "srcdoc" | "content") || name.starts_with("on")
4829 }
4830
4831 fn build_component_templates(
4833 &mut self,
4834 input: ComponentTemplateInput<'_>,
4835 ) -> Result<BuiltComponentTemplate> {
4836 let tag_name = input.tag_name;
4837 let synthesize_fast_shadow =
4838 input.artifact_html.is_some() && self.options.dom_strategy == DomStrategy::Shadow;
4839 let fast_html = synthesize_fast_shadow
4840 .then(|| Self::append_shadow_mode_if_missing(input.html.to_string()));
4841 let html = fast_html.as_deref().unwrap_or(input.html);
4842 let css_content = input.css_content;
4843 let dom_analysis = self.analyze_component_dom(tag_name, html, input.authored_html)?;
4844 let is_light = !dom_analysis.uses_shadow_dom;
4845 let mut inline_style_ranges = Vec::new();
4846 if is_light {
4847 let mut comment_ranges = Vec::new();
4848 Self::collect_html_comment_and_style_ranges(
4849 html,
4850 &mut comment_ranges,
4851 &mut inline_style_ranges,
4852 )?;
4853 if let Some(css) = css_content {
4854 css_light::validate_global_css(tag_name, css)?;
4855 }
4856 for (start, end) in &inline_style_ranges {
4857 css_light::validate_global_css(tag_name, &html[*start..*end])?;
4858 }
4859 }
4860 if self.component_registry.contains(tag_name) {
4861 self.component_registry
4862 .prepare_policy_css(tag_name, dom_analysis.uses_shadow_dom)?;
4863 }
4864 let css_content = self
4865 .component_registry
4866 .get(tag_name)
4867 .and_then(|component| component.css_content.as_deref())
4868 .or(css_content);
4869 let adopted_specifier = match self.options.css_strategy {
4870 CssStrategy::Module if css_content.is_some() => Some(tag_name),
4871 _ => None,
4872 };
4873 let style = if dom_analysis.uses_shadow_dom && self.plugin.is_some() {
4883 self.component_style_delivery(tag_name, css_content)
4884 } else {
4885 None
4886 };
4887
4888 let runtime_attr_source = match dom_analysis.authored_shadow_root {
4889 Some((start, end)) => &html[start..end],
4890 None => html,
4891 };
4892 let artifact_source = if input.artifact_needed {
4893 input.artifact_html.or_else(|| {
4894 Self::template_has_stripped_runtime_attrs(runtime_attr_source).then_some(html)
4895 })
4896 } else {
4897 None
4898 };
4899 let policy_wrapper = parse_component_render_policy(tag_name, html)?.is_authored();
4904 let preserve_root_bindings = self.component_processing.process_root_template_attributes;
4906 let styles_at_end = matches!(self.options.css_strategy, CssStrategy::Style)
4908 && self.component_processing.inline_styles_after_content;
4909 let ssr = self.process_component_template_with_mode(
4910 html,
4911 ComponentStyleInjection {
4912 css_snippet: None,
4913 adopted_specifier,
4914 },
4915 dom_analysis,
4916 ComponentTemplateMode {
4917 preserve_runtime_attrs: preserve_root_bindings,
4918 policy_wrapper,
4919 styles_at_end,
4920 },
4921 )?;
4922 let artifact = match artifact_source {
4923 Some(source) => {
4924 let fast_artifact = synthesize_fast_shadow
4925 .then(|| Self::append_shadow_mode_if_missing(source.to_string()));
4926 let source = fast_artifact.as_deref().unwrap_or(source);
4927 let artifact_dom_analysis = analyze_component_dom_with_diagnostic_source(
4928 tag_name,
4929 source,
4930 self.options.dom_strategy,
4931 input.authored_html,
4932 )?;
4933 Some(self.process_component_template_with_mode(
4934 source,
4935 ComponentStyleInjection {
4936 css_snippet: None,
4937 adopted_specifier,
4938 },
4939 artifact_dom_analysis,
4940 ComponentTemplateMode {
4941 preserve_runtime_attrs: true,
4944 policy_wrapper,
4945 styles_at_end,
4946 },
4947 )?)
4948 }
4949 None => None,
4950 };
4951 Ok(BuiltComponentTemplate {
4952 ssr,
4953 artifact,
4954 uses_shadow_dom: dom_analysis.uses_shadow_dom,
4955 style,
4956 })
4957 }
4958
4959 fn component_style_delivery(
4964 &self,
4965 tag_name: &str,
4966 css_content: Option<&str>,
4967 ) -> Option<OwnedComponentStyle> {
4968 let css = css_content?;
4969 Some(match self.options.css_strategy {
4970 CssStrategy::Link => {
4971 OwnedComponentStyle::Link(self.options.css_link_options.resolve(tag_name, css).href)
4972 }
4973 CssStrategy::Style => OwnedComponentStyle::Inline(css.trim().to_string()),
4974 CssStrategy::Module => OwnedComponentStyle::Adopted(tag_name.to_string()),
4975 })
4976 }
4977
4978 #[cfg(test)]
5003 fn process_component_template(
5004 &mut self,
5005 html: &str,
5006 css_snippet: Option<&str>,
5007 adopted_specifier: Option<&str>,
5008 ) -> Result<String> {
5009 let dom_analysis = analyze_component_dom("component", html, self.options.dom_strategy)?;
5010 self.process_component_template_with_mode(
5011 html,
5012 ComponentStyleInjection {
5013 css_snippet,
5014 adopted_specifier,
5015 },
5016 dom_analysis,
5017 ComponentTemplateMode {
5018 preserve_runtime_attrs: false,
5019 policy_wrapper: false,
5020 styles_at_end: false,
5021 },
5022 )
5023 }
5024
5025 fn process_component_template_with_mode(
5026 &mut self,
5027 html: &str,
5028 style: ComponentStyleInjection<'_>,
5029 dom_analysis: ComponentDomAnalysis,
5030 mode: ComponentTemplateMode,
5031 ) -> Result<String> {
5032 let trimmed_end = html.trim_end();
5033 let (trimmed, content_start) = html::leading_content(trimmed_end);
5034 let snippet = style.css_snippet.unwrap_or_default();
5035
5036 let processed = if let Some((root_start, root_end)) = dom_analysis.authored_shadow_root {
5037 let trim_start = content_start;
5038 let trim_end = html.trim_end().len();
5039 if root_start < trim_start || root_end > trim_end || root_start >= root_end {
5040 return Err(ParserError::Html(
5041 "invalid declarative Shadow DOM source range".to_string(),
5042 ));
5043 }
5044 let root = &html[root_start..root_end];
5045 let base = self.strip_template_build_attrs(root, mode.preserve_runtime_attrs);
5048 let with_adopted = Self::append_adopted_attr_if_missing(base, style.adopted_specifier);
5049 let root =
5050 Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end);
5051 let mut result =
5052 String::with_capacity(trimmed.len() + root.len() - (root_end - root_start));
5053 result.push_str(&html[trim_start..root_start]);
5054 result.push_str(&root);
5055 result.push_str(&html[root_end..trim_end]);
5056 result
5057 } else if let Some((content_start, content_end)) = dom_analysis.authored_light_root {
5058 let base = html[content_start..content_end].to_string();
5059 if snippet.is_empty() {
5060 base
5061 } else {
5062 let mut result = String::with_capacity(snippet.len() + base.len());
5063 result.push_str(snippet);
5064 result.push_str(&base);
5065 result
5066 }
5067 } else if dom_analysis.uses_shadow_dom {
5068 if mode.policy_wrapper {
5069 let base = self.strip_template_build_attrs(trimmed, mode.preserve_runtime_attrs);
5070 let with_shadow = Self::append_shadow_mode_if_missing(base);
5071 let with_adopted =
5072 Self::append_adopted_attr_if_missing(with_shadow, style.adopted_specifier);
5073 Self::inject_css_snippet_into_template(with_adopted, snippet, mode.styles_at_end)
5074 } else {
5075 let adopted = style.adopted_specifier.unwrap_or_default();
5076 let adopted_extra = if adopted.is_empty() {
5077 0
5078 } else {
5079 Self::adopted_attr_len(adopted)
5080 };
5081 let mut result =
5082 String::with_capacity(45 + adopted_extra + snippet.len() + trimmed.len());
5083 result.push_str("<template shadowrootmode=\"open\"");
5084 if !adopted.is_empty() {
5085 Self::push_adopted_attr(&mut result, adopted);
5086 }
5087 result.push('>');
5088 if mode.styles_at_end {
5089 result.push_str(trimmed);
5090 result.push_str(snippet);
5091 } else {
5092 result.push_str(snippet);
5093 result.push_str(trimmed);
5094 }
5095 result.push_str("</template>");
5096 result
5097 }
5098 } else {
5099 let base = if mode.policy_wrapper {
5104 Self::unwrap_component_template(trimmed)
5105 } else {
5106 trimmed.to_string()
5107 };
5108 if snippet.is_empty() {
5109 base
5110 } else {
5111 let mut result = String::with_capacity(snippet.len() + base.len());
5112 result.push_str(snippet);
5113 result.push_str(&base);
5114 result
5115 }
5116 };
5117
5118 self.strip_template_comments(processed)
5119 }
5120
5121 fn inject_css_snippet_into_template(html: String, snippet: &str, at_end: bool) -> String {
5122 if snippet.is_empty() {
5123 return html;
5124 }
5125
5126 if at_end {
5133 if let Some(close_start) = html.rfind("</template>") {
5134 let mut result = String::with_capacity(html.len() + snippet.len());
5135 result.push_str(&html[..close_start]);
5136 result.push_str(snippet);
5137 result.push_str(&html[close_start..]);
5138 return result;
5139 }
5140 }
5141
5142 match html::find_tag_close(&html) {
5143 Some(open_end) => {
5144 let mut result = String::with_capacity(html.len() + snippet.len());
5145 result.push_str(&html[..=open_end]);
5146 result.push_str(snippet);
5147 result.push_str(&html[open_end + 1..]);
5148 result
5149 }
5150 None => html,
5153 }
5154 }
5155
5156 fn append_adopted_attr_if_missing(html: String, adopted_specifier: Option<&str>) -> String {
5157 let Some(adopted) = adopted_specifier else {
5158 return html;
5159 };
5160 let Some(tag) = html::parse_tag(&html) else {
5161 return html;
5162 };
5163 if !tag.name.eq_ignore_ascii_case("template")
5164 || tag.closing
5165 || tag.attrs().any(|attr| {
5166 attr.name
5167 .eq_ignore_ascii_case(Self::ADOPTED_STYLESHEETS_ATTR)
5168 })
5169 {
5170 return html;
5171 }
5172
5173 let mut result = String::with_capacity(html.len() + Self::adopted_attr_len(adopted));
5174 result.push_str(&html[..tag.close]);
5175 Self::push_adopted_attr(&mut result, adopted);
5176 result.push_str(&html[tag.close..]);
5177 result
5178 }
5179
5180 fn append_shadow_mode_if_missing(html: String) -> String {
5181 let Some(tag) = html::parse_tag(&html) else {
5182 return html;
5183 };
5184 if !tag.name.eq_ignore_ascii_case("template")
5185 || tag.closing
5186 || tag
5187 .attrs()
5188 .any(|attr| attr.name.eq_ignore_ascii_case("shadowrootmode"))
5189 {
5190 return html;
5191 }
5192
5193 let mut result = String::with_capacity(html.len() + 22);
5194 result.push_str(&html[..tag.close]);
5195 result.push_str(" shadowrootmode=\"open\"");
5196 result.push_str(&html[tag.close..]);
5197 result
5198 }
5199
5200 fn unwrap_component_template(html: &str) -> String {
5201 let Some(tag) = html::parse_tag(html) else {
5202 return html.to_string();
5203 };
5204 let inner_start = tag.close + 1;
5205 let inner_end = html.rfind("</template>").unwrap_or(html.len());
5206 if inner_start >= inner_end {
5207 return String::new();
5208 }
5209 html[inner_start..inner_end].to_string()
5210 }
5211
5212 fn push_adopted_attr(out: &mut String, adopted: &str) {
5213 out.push(' ');
5214 out.push_str(Self::ADOPTED_STYLESHEETS_ATTR);
5215 out.push_str("=\"");
5216 out.push_str(adopted);
5217 out.push('"');
5218 }
5219
5220 fn adopted_attr_len(adopted: &str) -> usize {
5221 4 + Self::ADOPTED_STYLESHEETS_ATTR.len() + adopted.len()
5222 }
5223
5224 fn template_has_stripped_runtime_attrs(html: &str) -> bool {
5225 let (trimmed, _) = html::leading_content(html);
5226 let Some(tag) = html::parse_tag(trimmed) else {
5227 return false;
5228 };
5229 if !tag.name.eq_ignore_ascii_case("template") || tag.closing {
5230 return false;
5231 }
5232 tag.attrs().any(|attr| {
5233 attr.name.starts_with('@') || attr.name.starts_with(':') || attr.name.starts_with('?')
5234 })
5235 }
5236
5237 fn strip_template_comments(&mut self, html: String) -> Result<String> {
5238 let mut ranges = Vec::new();
5239 let mut style_ranges = Vec::new();
5240 Self::collect_html_comment_and_style_ranges(&html, &mut ranges, &mut style_ranges)?;
5241
5242 for (style_start, style_end) in style_ranges {
5243 let css = &html[style_start..style_end];
5244 let (_defs, _requirements, comments) = self
5245 .css_parser
5246 .extract_definitions_requirements_and_comments(css, self.options.legal_comments)
5247 .map_err(|e| self.css_diagnostic(e))?;
5248 for comment in comments {
5249 let comment_text = &css[comment.start_byte..comment.end_byte];
5250 if comment.preserve || self.css_signal_comment_fragment(comment_text).is_some() {
5251 continue;
5252 }
5253 ranges.push((
5254 style_start + comment.start_byte,
5255 style_start + comment.end_byte,
5256 ));
5257 }
5258 }
5259
5260 if ranges.is_empty() {
5261 return Ok(html);
5262 }
5263
5264 Ok(comment_policy::strip_ranges(&html, ranges.as_mut_slice()).into_owned())
5265 }
5266
5267 fn collect_html_comment_and_style_ranges(
5269 html: &str,
5270 comment_ranges: &mut Vec<(usize, usize)>,
5271 style_ranges: &mut Vec<(usize, usize)>,
5272 ) -> Result<()> {
5273 let mut index = 0usize;
5274 while index < html.len() {
5275 let remaining = &html[index..];
5276 if remaining.starts_with("<!--") {
5277 let Some(end) = html::find_comment_close(remaining) else {
5278 return Err(Diagnostic::error("unterminated HTML comment")
5279 .code(codes::UNTERMINATED_HTML_COMMENT)
5280 .at_offset(html, index)
5281 .help("close the comment with `-->`")
5282 .into());
5283 };
5284 comment_ranges.push((index, index + end));
5285 index += end;
5286 continue;
5287 }
5288
5289 if remaining.starts_with('<') {
5290 if let Some((style_start, style_end, close_end)) =
5291 html::style_element_bounds(remaining)
5292 {
5293 style_ranges.push((index + style_start, index + style_end));
5294 index += close_end;
5295 continue;
5296 }
5297
5298 if let Some(tag) = html::parse_tag(remaining) {
5299 index += tag.close + 1;
5300 continue;
5301 }
5302 }
5303
5304 let Some(ch) = remaining.chars().next() else {
5305 break;
5306 };
5307 index += ch.len_utf8();
5308 }
5309 Ok(())
5310 }
5311
5312 fn strip_template_build_attrs(&mut self, html: &str, preserve_runtime_attrs: bool) -> String {
5318 let Some(tag) = html::parse_tag(html) else {
5319 return html.to_string();
5320 };
5321
5322 let mut removals: Vec<(usize, usize)> = Vec::new();
5323 for attr in tag.attrs() {
5324 let runtime_attr = attr.name.starts_with('@')
5325 || attr.name.starts_with(':')
5326 || attr.name.starts_with('?');
5327 let policy_attr = matches!(
5328 attr.name,
5329 COMPONENT_RENDER_ATTR | COMPONENT_HYDRATE_ATTR | COMPONENT_RESERVE_BLOCK_SIZE_ATTR
5330 );
5331 if policy_attr || (!preserve_runtime_attrs && runtime_attr) {
5332 let mut start = attr.raw_range.start;
5333 while start > 0 && html.as_bytes()[start - 1].is_ascii_whitespace() {
5334 start -= 1;
5335 }
5336 removals.push((start, attr.raw_range.end));
5337 }
5338 }
5339
5340 if removals.is_empty() {
5341 return html.to_string();
5342 }
5343
5344 let mut result = String::with_capacity(html.len());
5346 let mut pos = 0;
5347 for (start, end) in &removals {
5348 result.push_str(&html[pos..*start]);
5349 pos = *end;
5350 }
5351 result.push_str(&html[pos..]);
5352 result
5353 }
5354}
5355
5356#[cfg(test)]
5357mod tests {
5358 use webui_test_utils::*;
5359
5360 use super::*;
5361
5362 #[derive(Default)]
5363 struct ContextArtifactPlugin {
5364 artifacts: Vec<crate::plugin::ComponentTemplateArtifact>,
5365 }
5366
5367 impl ParserPlugin for ContextArtifactPlugin {
5368 fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5369 self.artifacts
5370 .push(crate::plugin::ComponentTemplateArtifact::template(
5371 context.component.tag_name.clone(),
5372 context.template.to_string(),
5373 context.uses_shadow_dom,
5374 ));
5375 Ok(())
5376 }
5377
5378 fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
5379 Ok(ParserPluginArtifacts::ComponentTemplates(self.artifacts))
5380 }
5381 }
5382
5383 fn structural_matcher(value: &str) -> FragmentMatcher {
5384 signal_raw(&format!("{STRUCTURAL_SIGNAL_PREFIX}{value}"))
5385 }
5386
5387 mod fast_parser;
5388
5389 fn boundary_matcher(name: &str, declaration: u32) -> FragmentMatcher {
5390 webui_test_utils::boundary(name, declaration)
5391 }
5392
5393 fn boundary_end_matcher(declaration: u32) -> FragmentMatcher {
5394 webui_test_utils::boundary_end(declaration)
5395 }
5396
5397 #[test]
5398 fn unwrapped_component_uses_configured_dom_strategy() {
5399 assert!(
5400 analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Shadow)
5401 .expect("valid component")
5402 .uses_shadow_dom
5403 );
5404 assert!(
5405 !analyze_component_dom("x-card", "<div>content</div>", DomStrategy::Light)
5406 .expect("valid component")
5407 .uses_shadow_dom
5408 );
5409 }
5410
5411 #[test]
5412 fn bare_template_explicitly_selects_light_dom_over_shadow_fallback() {
5413 let analysis = analyze_component_dom(
5414 "x-card",
5415 "<template><div>content</div></template>",
5416 DomStrategy::Shadow,
5417 )
5418 .expect("valid explicit Light wrapper");
5419 assert!(!analysis.uses_shadow_dom);
5420 assert!(analysis.authored_light_root.is_some());
5421
5422 let attributed = analyze_component_dom(
5423 "x-card",
5424 r#"<template data-purpose="content"><div>content</div></template>"#,
5425 DomStrategy::Shadow,
5426 )
5427 .expect("ordinary template remains valid");
5428 assert!(attributed.uses_shadow_dom);
5429 assert!(attributed.authored_light_root.is_none());
5430 }
5431
5432 #[test]
5433 fn plugin_artifacts_preserve_resolved_shadow_mode() {
5434 let component_html = r#"<template shadowrootmode="open"><p>shadow</p></template>"#;
5435 {
5436 let mut parser = HtmlParser::with_plugin_options(
5437 Box::new(ContextArtifactPlugin::default()),
5438 CssStrategy::Style,
5439 );
5440 parser
5441 .component_registry_mut()
5442 .register_component(ComponentRegistration::new(
5443 "x-card",
5444 component_html,
5445 None,
5446 false,
5447 ))
5448 .expect("register component");
5449 parser
5450 .parse("index.html", "<x-card></x-card>")
5451 .expect("parse component");
5452
5453 let ParserPluginArtifacts::ComponentTemplates(artifacts) =
5454 parser.take_plugin_artifacts().expect("consume artifacts")
5455 else {
5456 panic!("expected component artifacts");
5457 };
5458 assert!(artifacts[0].uses_shadow_dom);
5459 }
5460 }
5461
5462 #[test]
5463 fn open_shadow_wrapper_marks_component_shadow() {
5464 let html =
5465 "<!-- lead --><template shadowrootmode='open'><slot></slot></template><!-- tail -->";
5466 assert!(
5467 analyze_component_dom("x-card", html, DomStrategy::Light)
5468 .expect("valid Shadow wrapper")
5469 .uses_shadow_dom
5470 );
5471 }
5472
5473 #[test]
5474 fn closed_shadow_wrapper_is_rejected() {
5475 let error = analyze_component_dom(
5476 "x-card",
5477 "<template shadowrootmode=\"closed\"><p>content</p></template>",
5478 DomStrategy::Shadow,
5479 )
5480 .expect_err("closed mode must fail");
5481 assert!(matches!(
5482 error,
5483 ParserError::Template(ref diagnostic)
5484 if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5485 && diagnostic.component_name() == Some("x-card")
5486 ));
5487 }
5488
5489 #[test]
5490 fn shadow_wrapper_must_be_the_only_top_level_element() {
5491 let error = analyze_component_dom(
5492 "x-card",
5493 "<template shadowrootmode=\"open\"><p>content</p></template><span>extra</span>",
5494 DomStrategy::Light,
5495 )
5496 .expect_err("multiple roots must fail");
5497 assert!(matches!(
5498 error,
5499 ParserError::Template(ref diagnostic)
5500 if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5501 ));
5502 }
5503
5504 #[test]
5505 fn shadowrootmode_rejects_invalid_value_and_placement() {
5506 for html in [
5507 "<template shadowrootmode=\"invalid\"><p>content</p></template>",
5508 "<template shadowrootmode=\" open \"><p>content</p></template>",
5509 "<template :shadowrootmode=\"{{mode}}\"><p>content</p></template>",
5510 "<template shadowrootmode=\"open\" />",
5511 "<div><template shadowrootmode=\"open\"><p>content</p></template></div>",
5512 "<div shadowrootmode=\"open\">content</div>",
5513 ] {
5514 let error = analyze_component_dom("x-card", html, DomStrategy::Shadow)
5515 .expect_err("invalid shadowrootmode must fail");
5516 assert!(matches!(
5517 error,
5518 ParserError::Template(ref diagnostic)
5519 if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
5520 && diagnostic.component_name() == Some("x-card")
5521 && diagnostic.help_text().is_some()
5522 && diagnostic.snippet_text().is_some()
5523 ));
5524 }
5525 }
5526
5527 #[test]
5528 fn light_component_slot_is_rejected() {
5529 let source = "<div><slot name=\"label\"></slot></div>";
5530 let error = analyze_component_dom("x-card", source, DomStrategy::Light)
5531 .expect_err("Light slot must fail");
5532 assert!(matches!(
5533 error,
5534 ParserError::Template(ref diagnostic)
5535 if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5536 && diagnostic.help_text().is_some_and(|help| help.contains("shadowrootmode"))
5537 ));
5538 assert!(
5539 analyze_component_dom("x-card", source, DomStrategy::Shadow)
5540 .expect("implicit Shadow components support slots")
5541 .uses_shadow_dom
5542 );
5543
5544 let explicit_light = "<template><slot name=\"label\"></slot></template>";
5545 let error = analyze_component_dom("x-card", explicit_light, DomStrategy::Shadow)
5546 .expect_err("bare template explicitly selects Light");
5547 assert!(matches!(
5548 error,
5549 ParserError::Template(ref diagnostic)
5550 if diagnostic.error_code() == Some(codes::LIGHT_DOM_SLOT)
5551 ));
5552 }
5553
5554 #[test]
5555 fn webui_plugin_records_authored_shadow_wrapper() {
5556 let mut parser =
5557 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
5558 parser
5559 .component_registry_mut()
5560 .register_component(ComponentRegistration::new(
5561 "x-card",
5562 "<!-- lead --><template shadowrootmode=\"open\" @click=\"{onClick()}\"><slot></slot></template><!-- tail -->",
5563 None,
5564 true,
5565 ))
5566 .expect("register component");
5567 parser
5568 .parse("index.html", "<x-card>projected</x-card>")
5569 .expect("parse component");
5570
5571 let ParserPluginArtifacts::ComponentTemplates(templates) =
5572 parser.take_plugin_artifacts().expect("plugin artifacts")
5573 else {
5574 panic!("expected component templates");
5575 };
5576 assert!(templates[0].uses_shadow_dom);
5577 assert!(templates[0].template_json.contains("\"sd\":1"));
5578 assert!(templates[0]
5579 .template_json
5580 .contains(r#""re":[["click","onClick",[]]]"#));
5581 }
5582
5583 struct StyleContextPlugin {
5586 captured: std::rc::Rc<std::cell::RefCell<Vec<(bool, Option<String>, String)>>>,
5587 }
5588
5589 impl crate::plugin::ParserPlugin for StyleContextPlugin {
5590 fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
5591 self.captured.borrow_mut().push((
5592 context.uses_shadow_dom,
5593 context.style.map(|style| format!("{style:?}")),
5594 context.template.to_string(),
5595 ));
5596 Ok(())
5597 }
5598 }
5599
5600 fn capture_style_context(
5601 strategy: CssStrategy,
5602 dom_strategy: DomStrategy,
5603 template: &str,
5604 css: Option<&str>,
5605 ) -> (bool, Option<String>, String) {
5606 let captured = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
5607 let mut parser = HtmlParser::with_plugin_options(
5608 Box::new(StyleContextPlugin {
5609 captured: std::rc::Rc::clone(&captured),
5610 }),
5611 ParserOptions {
5612 css_strategy: strategy,
5613 dom_strategy,
5614 ..ParserOptions::default()
5615 },
5616 );
5617 parser
5618 .component_registry_mut()
5619 .register_component(ComponentRegistration::new("x-card", template, css, true))
5620 .expect("register component");
5621 parser
5622 .parse("index.html", "<x-card></x-card>")
5623 .expect("parse component");
5624 let captured = captured.borrow();
5625 captured.first().cloned().expect("component registered")
5626 }
5627
5628 #[test]
5632 fn parser_reports_shadow_style_delivery_without_injecting_it() {
5633 for (strategy, expected) in [
5634 (CssStrategy::Link, "Link { href: \"x-card.css\" }"),
5635 (CssStrategy::Style, "Inline { css: \".card{color:red}\" }"),
5636 (CssStrategy::Module, "Adopted { specifier: \"x-card\" }"),
5637 ] {
5638 let (uses_shadow_dom, style, template) = capture_style_context(
5639 strategy,
5640 DomStrategy::Shadow,
5641 "<template shadowrootmode=\"open\" shadowrootadoptedstylesheets=\"x-card\"><div class=\"card\"></div></template>",
5642 Some(".card{color:red}"),
5643 );
5644 assert!(uses_shadow_dom, "{strategy:?} should be shadow");
5645 assert_eq!(style.as_deref(), Some(expected), "{strategy:?} delivery");
5646 assert!(
5647 !template.contains("<style>") && !template.contains("<link"),
5648 "{strategy:?} template must stay injection-free: {template}"
5649 );
5650 }
5651 }
5652
5653 #[test]
5656 fn parser_reports_no_style_delivery_for_light_components() {
5657 let (uses_shadow_dom, style, _) = capture_style_context(
5658 CssStrategy::Style,
5659 DomStrategy::Light,
5660 "<div class=\"card\"></div>",
5661 Some(".card{color:red}"),
5662 );
5663 assert!(!uses_shadow_dom);
5664 assert_eq!(style, None);
5665 }
5666
5667 #[test]
5669 fn parser_reports_no_style_delivery_without_css() {
5670 let (uses_shadow_dom, style, _) = capture_style_context(
5671 CssStrategy::Style,
5672 DomStrategy::Shadow,
5673 "<template shadowrootmode=\"open\"><div></div></template>",
5674 None,
5675 );
5676 assert!(uses_shadow_dom);
5677 assert_eq!(style, None);
5678 }
5679
5680 #[test]
5681 fn test_plugin_display_names() {
5682 assert_eq!(Plugin::Fast.to_string(), "fast");
5683 assert_eq!(Plugin::FastV2.to_string(), "fast-v2");
5684 assert_eq!(Plugin::FastV3.to_string(), "fast-v3");
5685 assert_eq!(Plugin::WebUI.to_string(), "webui");
5686 }
5687
5688 #[test]
5689 fn test_plugin_from_str_names() {
5690 assert_eq!("fast".parse::<Plugin>(), Ok(Plugin::Fast));
5691 assert_eq!("fast-v2".parse::<Plugin>(), Ok(Plugin::FastV2));
5692 assert_eq!("fast-v3".parse::<Plugin>(), Ok(Plugin::FastV3));
5693 assert_eq!("webui".parse::<Plugin>(), Ok(Plugin::WebUI));
5694 }
5695
5696 #[test]
5697 fn test_plugin_from_str_unknown_mentions_supported_names() {
5698 let err = "legacy".parse::<Plugin>().unwrap_err();
5699 assert!(
5700 err.contains("fast-v3"),
5701 "error should mention fast-v3: {err}"
5702 );
5703 assert!(
5704 err.contains("fast-v2"),
5705 "error should mention fast-v2: {err}"
5706 );
5707 assert!(err.contains("fast"), "error should mention fast: {err}");
5708 }
5709
5710 #[test]
5711 fn test_parse_signal() {
5712 let mut parser = HtmlParser::new();
5713 let html = "Hello, {{name}}!";
5714 let result = parser.parse("test.html", html);
5715
5716 assert!(result.is_ok());
5717 let fragment_records = parser.into_fragment_records();
5718
5719 assert_stream!(
5720 fragment_records,
5721 "test.html",
5722 [raw("Hello, "), signal("name"), raw("!"),]
5723 );
5724 }
5725
5726 #[test]
5727 fn test_parse_raw_signal() {
5728 let mut parser = HtmlParser::new();
5729 let html = "Hello, {{{html_content}}}!";
5730 let result = parser.parse("test.html", html);
5731
5732 assert!(result.is_ok());
5733 let fragment_records = parser.into_fragment_records();
5734
5735 assert_stream!(
5736 fragment_records,
5737 "test.html",
5738 [raw("Hello, "), signal_raw("html_content"), raw("!"),]
5739 );
5740 }
5741
5742 #[test]
5743 fn test_parse_preserves_inline_spaces_around_entity_between_bindings() {
5744 let mut parser = HtmlParser::new();
5745 let html = "<nav>{{sectionName}} > {{topicName}}</nav>";
5746 let result = parser.parse("test.html", html);
5747
5748 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5749 let fragment_records = parser.into_fragment_records();
5750
5751 assert_stream!(
5752 fragment_records,
5753 "test.html",
5754 [
5755 raw("<nav>"),
5756 signal("sectionName"),
5757 raw(" > "),
5758 signal("topicName"),
5759 raw("</nav>"),
5760 ]
5761 );
5762 }
5763
5764 #[test]
5765 fn test_invalid_for_each_is_template_diagnostic() {
5766 let mut parser = HtmlParser::new();
5767 let err = parser
5768 .parse(
5769 "index.html",
5770 "<div>\n <for each=\"person inpeople\"><p>x</p></for>\n</div>",
5771 )
5772 .expect_err("invalid for-each must error");
5773 assert!(matches!(err, ParserError::Template(_)));
5774 let msg = err.to_string();
5775 assert!(msg.contains("invalid <for> each expression"), "{msg}");
5776 assert!(msg.contains(r#"each="person inpeople""#), "{msg}");
5777 assert!(
5780 msg.contains("--> index.html:2:3"),
5781 "missing line:column — {msg}"
5782 );
5783 assert!(msg.contains("help:"), "{msg}");
5784 }
5785
5786 #[test]
5787 fn test_invalid_if_condition_is_template_diagnostic() {
5788 let mut parser = HtmlParser::new();
5789 let err = parser
5790 .parse("index.html", r#"<if condition="a +"><p>x</p></if>"#)
5791 .expect_err("invalid condition must error");
5792 assert!(matches!(err, ParserError::Template(_)));
5793 let msg = err.to_string();
5794 assert!(msg.contains("invalid <if> condition expression"), "{msg}");
5795 assert!(
5796 msg.contains("--> index.html:1:1"),
5797 "missing line:column — {msg}"
5798 );
5799 }
5800
5801 #[test]
5802 fn test_parse_for_directive() {
5803 let mut parser = HtmlParser::new();
5804 let html = r#"<for each="item in items"><div class="item">{{item.name}}</div></for>"#;
5805
5806 let result = parser.parse("test.html", html);
5807 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5808 let fragment_records = parser.into_fragment_records();
5809
5810 assert_stream!(
5811 fragment_records,
5812 "test.html",
5813 [for_loop("item", "items", "for-1"),]
5814 );
5815
5816 assert_stream!(
5818 fragment_records,
5819 "for-1",
5820 [
5821 raw("<div class=\"item\">"),
5822 signal("item.name"),
5823 raw("</div>"),
5824 ]
5825 );
5826 }
5827
5828 #[test]
5829 fn test_parse_if_directive() {
5830 let mut parser = HtmlParser::new();
5831 let html = r#"<if condition="isLoggedIn"><div>Welcome back, {{username}}!</div></if>"#;
5832
5833 let result = parser.parse("test.html", html);
5834 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5835 let fragment_records = parser.into_fragment_records();
5836
5837 assert_stream!(fragment_records, "test.html", [if_cond("if-1"),]);
5838
5839 assert_stream!(
5841 fragment_records,
5842 "if-1",
5843 [
5844 raw("<div>Welcome back, "),
5845 signal("username"),
5846 raw("!</div>"),
5847 ]
5848 );
5849 }
5850
5851 #[test]
5852 fn test_component_directive() {
5853 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5854 parser
5855 .component_registry
5856 .register_component(ComponentRegistration::new(
5857 "my-component",
5858 "<div>My Component</div>",
5859 Some("div { color: blue; }"),
5860 true,
5861 ))
5862 .expect("Failed to register component");
5863
5864 let result = parser.parse("test.html", "<my-component></my-component>");
5865 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5866 let records = parser.into_fragment_records();
5867
5868 assert_stream!(
5869 records,
5870 "test.html",
5871 [
5872 raw("<my-component"),
5873 structural_matcher("streaming_root:my-component"),
5874 raw(">"),
5875 component("my-component"),
5876 raw("</my-component>"),
5877 ]
5878 );
5879
5880 let comp = &records["my-component"].fragments;
5883 assert_eq!(comp.len(), 1);
5884 assert!(
5885 matches!(comp[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
5886 !raw.value.contains("<template shadowrootmode")
5887 && raw.value.contains("<div")
5888 && raw.value.contains(">My Component</div>"))
5889 );
5890 }
5891
5892 #[test]
5893 fn shadow_component_emits_style_hook_inside_declarative_root() {
5894 let mut parser = HtmlParser::new();
5895 parser
5896 .component_registry
5897 .register_component(ComponentRegistration::new(
5898 "my-component",
5899 r#"<template shadowrootmode="open"><div>My Component</div></template>"#,
5900 Some("div { color: blue; }"),
5901 true,
5902 ))
5903 .expect("register");
5904
5905 parser
5906 .parse("test.html", "<my-component></my-component>")
5907 .expect("parse");
5908 let records = parser.into_fragment_records();
5909 assert_stream!(
5910 records,
5911 "my-component",
5912 [
5913 raw("<template shadowrootmode=\"open\">"),
5914 structural_matcher("shadow_styles:my-component"),
5915 raw("<div>My Component</div></template>"),
5916 ]
5917 );
5918 assert!(
5919 records["my-component"]
5920 .fragments
5921 .iter()
5922 .all(|fragment| !matches!(
5923 fragment.fragment.as_ref(),
5924 Some(Fragment::Raw(raw)) if raw.value.contains("color: blue")
5925 )),
5926 "component CSS is delivered by the handler closure"
5927 );
5928 }
5929
5930 #[test]
5931 fn authored_shadow_attribute_name_is_ascii_case_insensitive_for_style_hook() {
5932 let mut parser = HtmlParser::new();
5933 parser
5934 .component_registry
5935 .register_component(ComponentRegistration::new(
5936 "my-component",
5937 r#"<template shadowRootMode="open"><slot></slot></template>"#,
5938 None,
5939 true,
5940 ))
5941 .expect("register");
5942
5943 parser
5944 .parse("test.html", "<my-component></my-component>")
5945 .expect("parse");
5946 let records = parser.into_fragment_records();
5947 assert_stream!(
5948 records,
5949 "my-component",
5950 [
5951 raw(r#"<template shadowRootMode="open">"#),
5952 structural_matcher("shadow_styles:my-component"),
5953 raw("<slot></slot></template>"),
5954 ]
5955 );
5956 }
5957
5958 #[test]
5959 fn streaming_root_signal_emitted_before_component_opening_tag_closes() {
5960 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5965 parser
5966 .component_registry
5967 .register_component(ComponentRegistration::new(
5968 "my-widget",
5969 "<div>w</div>",
5970 None,
5971 true,
5972 ))
5973 .expect("register");
5974
5975 let result = parser.parse("test.html", "<my-widget></my-widget>");
5976 assert!(result.is_ok(), "Parse error: {:?}", result.err());
5977 let records = parser.into_fragment_records();
5978
5979 assert_stream!(
5980 records,
5981 "test.html",
5982 [
5983 raw("<my-widget"),
5984 structural_matcher("streaming_root:my-widget"),
5985 raw(">"),
5986 component("my-widget"),
5987 raw("</my-widget>"),
5988 ]
5989 );
5990 }
5991
5992 #[test]
5993 fn streaming_root_signal_emitted_per_component_when_nested_and_repeated() {
5994 use webui_protocol::web_ui_fragment::Fragment;
5997 let mut parser = HtmlParser::new();
5998 for name in ["outer-box", "inner-pill"] {
5999 parser
6000 .component_registry
6001 .register_component(ComponentRegistration::new(
6002 name,
6003 "<div>content</div>",
6004 None,
6005 true,
6006 ))
6007 .expect("register");
6008 }
6009
6010 let result = parser.parse(
6011 "test.html",
6012 "<outer-box><inner-pill></inner-pill></outer-box><inner-pill></inner-pill>",
6013 );
6014 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6015 let records = parser.into_fragment_records();
6016 let fragments = &records["test.html"].fragments;
6017
6018 let signals: Vec<&str> = fragments
6019 .iter()
6020 .filter_map(|f| match f.fragment.as_ref() {
6021 Some(Fragment::Signal(s)) if s.raw => Some(s.value.as_str()),
6022 _ => None,
6023 })
6024 .collect();
6025 assert_eq!(
6026 signals,
6027 [
6028 "}}}webui:streaming_root:outer-box",
6029 "}}}webui:streaming_root:inner-pill",
6030 "}}}webui:streaming_root:inner-pill",
6031 ]
6032 );
6033
6034 for (i, frag) in fragments.iter().enumerate() {
6037 let Some(Fragment::Signal(s)) = frag.fragment.as_ref() else {
6038 continue;
6039 };
6040 if !s.raw || !s.value.starts_with("}}}webui:streaming_root:") {
6041 continue;
6042 }
6043 match fragments[i + 1].fragment.as_ref() {
6044 Some(Fragment::Raw(r)) => assert_eq!(r.value, ">"),
6045 other => panic!("expected raw(\">\") after signal, got {other:?}"),
6046 }
6047 }
6048 }
6049
6050 #[test]
6051 fn unknown_component_typo_in_same_namespace_errors_with_suggestion() {
6052 let mut parser = HtmlParser::new();
6053 parser
6054 .component_registry
6055 .register_component(ComponentRegistration::new(
6056 "mp-button",
6057 "<button>b</button>",
6058 None,
6059 true,
6060 ))
6061 .expect("Failed to register component");
6062
6063 let err = parser
6065 .parse("test.html", "<mp-buton></mp-buton>")
6066 .expect_err("a near-typo component tag should error");
6067 let ParserError::Template(diag) = err else {
6068 panic!("expected ParserError::Template, got {err:?}");
6069 };
6070 assert_eq!(diag.error_code(), Some(codes::UNKNOWN_COMPONENT));
6071 let help = diag
6072 .help_text()
6073 .expect("diagnostic should carry a help line");
6074 assert!(
6075 help.contains("did you mean <mp-button>?"),
6076 "expected a suggestion, got: {help}"
6077 );
6078 }
6079
6080 #[test]
6081 fn external_custom_element_in_other_namespace_passes_through() {
6082 let mut parser = HtmlParser::new();
6083 parser
6084 .component_registry
6085 .register_component(ComponentRegistration::new(
6086 "mp-button",
6087 "<button>b</button>",
6088 None,
6089 true,
6090 ))
6091 .expect("Failed to register component");
6092
6093 let result = parser.parse("test.html", "<md-button></md-button>");
6096 assert!(
6097 result.is_ok(),
6098 "external custom element should pass through, got: {:?}",
6099 result.err()
6100 );
6101 }
6102
6103 #[test]
6104 fn for_missing_each_suggests_typoed_attribute() {
6105 let mut parser = HtmlParser::new();
6106 let err = parser
6108 .parse("test.html", "<for eahc=\"todo in todos\"></for>")
6109 .expect_err("a <for> without `each` should error");
6110 let ParserError::Template(diag) = err else {
6111 panic!("expected ParserError::Template, got {err:?}");
6112 };
6113 assert_eq!(diag.error_code(), Some(codes::MISSING_FOR_EACH));
6114 let help = diag
6115 .help_text()
6116 .expect("diagnostic should carry a help line");
6117 assert!(
6118 help.contains("found `eahc`") && help.contains("did you mean"),
6119 "expected an attribute-typo suggestion, got: {help}"
6120 );
6121 }
6122
6123 #[test]
6124 fn parent_diagnostic_owner_survives_nested_component_parse() {
6125 let mut parser = HtmlParser::new();
6130 parser
6131 .component_registry
6132 .register_component(ComponentRegistration::new(
6133 "my-card",
6134 "<div>card</div>",
6135 None,
6136 true,
6137 ))
6138 .expect("register");
6139
6140 let html = r#"<my-card></my-card><for each="bad inval"></for>"#;
6142 let err = parser
6143 .parse("index.html", html)
6144 .expect_err("the malformed <for> should error");
6145 let ParserError::Template(diag) = err else {
6146 panic!("expected ParserError::Template, got {err:?}");
6147 };
6148 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_EACH));
6149 assert_eq!(
6150 diag.component_name(),
6151 Some("index.html"),
6152 "parent diagnostic must be owned by index.html, not the nested component"
6153 );
6154 }
6155
6156 #[test]
6157 fn test_component_directive_with_slots() {
6158 let mut parser = HtmlParser::new();
6159 parser
6160 .component_registry
6161 .register_component(ComponentRegistration::new(
6162 "my-component",
6163 "<div>My Component</div>",
6164 Some("div { color: blue; }"),
6165 true,
6166 ))
6167 .expect("Failed to register component");
6168
6169 let result = parser.parse(
6170 "test.html",
6171 "Hello<my-component><p>World</p></my-component>",
6172 );
6173 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6174 let records = parser.into_fragment_records();
6175 let fragments = &records["test.html"].fragments;
6176
6177 assert!(fragments.len() >= 3);
6179 assert!(
6182 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("Hello") && raw.value.contains("<my-component"))
6183 );
6184 assert!(fragments.iter().any(|f| matches!(
6186 f.fragment.as_ref(),
6187 Some(Fragment::Component(c)) if c.fragment_id == "my-component"
6188 )));
6189 let last = fragments.last().unwrap();
6191 assert!(
6192 matches!(last.fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</my-component>"))
6193 );
6194 }
6195
6196 #[test]
6197 fn test_shadow_dom_shell_fragment_graph_includes_child_components() {
6198 let mut parser = HtmlParser::new();
6203
6204 parser
6205 .component_registry
6206 .register_component(ComponentRegistration::new(
6207 "app-shell",
6208 r#"<template shadowrootmode="open">
6209 <my-navbar></my-navbar>
6210 <main><outlet /></main>
6211 <cart-panel></cart-panel>
6212 <my-footer></my-footer>
6213 </template>"#,
6214 Some(":host{display:flex}"),
6215 true,
6216 ))
6217 .expect("register app-shell");
6218 parser
6219 .component_registry
6220 .register_component(ComponentRegistration::new(
6221 "my-navbar",
6222 "<nav>Nav</nav>",
6223 Some("nav{color:red}"),
6224 true,
6225 ))
6226 .expect("register my-navbar");
6227 parser
6228 .component_registry
6229 .register_component(ComponentRegistration::new(
6230 "cart-panel",
6231 "<aside>Cart</aside>",
6232 Some("aside{color:green}"),
6233 true,
6234 ))
6235 .expect("register cart-panel");
6236 parser
6237 .component_registry
6238 .register_component(ComponentRegistration::new(
6239 "my-footer",
6240 "<footer>Footer</footer>",
6241 Some("footer{color:blue}"),
6242 true,
6243 ))
6244 .expect("register my-footer");
6245
6246 parser
6247 .parse("index.html", "<app-shell></app-shell>")
6248 .expect("parse index.html");
6249 let records = parser.into_fragment_records();
6250
6251 let app_frags = &records["app-shell"].fragments;
6253 let component_ids: Vec<&str> = app_frags
6254 .iter()
6255 .filter_map(|f| match f.fragment.as_ref() {
6256 Some(Fragment::Component(c)) => Some(c.fragment_id.as_str()),
6257 _ => None,
6258 })
6259 .collect();
6260
6261 assert!(
6262 component_ids.contains(&"my-navbar"),
6263 "app-shell fragments should contain my-navbar: {component_ids:?}"
6264 );
6265 assert!(
6266 component_ids.contains(&"cart-panel"),
6267 "app-shell fragments should contain cart-panel: {component_ids:?}"
6268 );
6269 assert!(
6270 component_ids.contains(&"my-footer"),
6271 "app-shell fragments should contain my-footer: {component_ids:?}"
6272 );
6273 }
6274
6275 #[test]
6278 fn test_component_no_double_wrap_template() {
6279 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6283 parser
6284 .component_registry
6285 .register_component(ComponentRegistration::new(
6286 "custom-element",
6287 r#"<template foo="bar"><div>content</div></template>"#,
6288 None,
6289 true,
6290 ))
6291 .expect("register");
6292 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6293 assert!(result.is_ok());
6294 let records = parser.into_fragment_records();
6295
6296 assert_fragments!(
6297 records["index.html"].fragments,
6298 [
6299 raw("<custom-element"),
6300 structural_matcher("streaming_root:custom-element"),
6301 raw(">"),
6302 component("custom-element"),
6303 raw("Hello</custom-element>"),
6304 ]
6305 );
6306
6307 assert_stream!(
6308 records,
6309 "custom-element",
6310 [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6311 );
6312 }
6313
6314 #[test]
6315 fn test_component_styled_no_double_wrap() {
6316 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6320 parser
6321 .component_registry
6322 .register_component(ComponentRegistration::new(
6323 "custom-element",
6324 r#"<template foo="bar"><div>content</div></template>"#,
6325 Some("div { color: red; }"),
6326 true,
6327 ))
6328 .expect("register");
6329 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6330 assert!(result.is_ok());
6331 let records = parser.into_fragment_records();
6332
6333 assert_stream!(
6334 records,
6335 "custom-element",
6336 [raw(r#"<template foo="bar"><div>content</div></template>"#),]
6337 );
6338 }
6339
6340 #[test]
6341 fn test_component_strip_runtime_attrs() {
6342 let mut parser = HtmlParser::new();
6346 parser
6347 .component_registry
6348 .register_component(ComponentRegistration::new(
6349 "custom-element",
6350 r#"<template shadowrootmode="open" @click={foo} :bar="baz" ?bool="true"><div>content</div></template>"#,
6351 None,
6352 true,
6353 ))
6354 .expect("register");
6355 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6356 assert!(result.is_ok());
6357 let records = parser.into_fragment_records();
6358
6359 assert_stream!(
6360 records,
6361 "custom-element",
6362 [
6363 raw(r#"<template shadowrootmode="open">"#),
6364 structural_matcher("shadow_styles:custom-element"),
6365 raw("<div>content</div></template>"),
6366 ]
6367 );
6368 }
6369
6370 #[test]
6371 fn test_component_strip_runtime_attrs_does_not_match_attr_value_text() {
6372 let mut parser = HtmlParser::new();
6373 parser
6374 .component_registry
6375 .register_component(ComponentRegistration::new(
6376 "custom-element",
6377 r#"<template shadowrootmode="open" data-note="@click={foo}" @click={foo}><div>content</div></template>"#,
6378 None,
6379 true,
6380 ))
6381 .expect("register");
6382 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6383 assert!(result.is_ok());
6384 let records = parser.into_fragment_records();
6385
6386 assert_stream!(
6387 records,
6388 "custom-element",
6389 [
6390 raw(r#"<template shadowrootmode="open" data-note="@click={foo}">"#),
6391 structural_matcher("shadow_styles:custom-element"),
6392 raw("<div>content</div></template>"),
6393 ]
6394 );
6395 }
6396
6397 #[test]
6398 fn test_component_strip_runtime_attrs_handles_duplicate_runtime_attrs() {
6399 let mut parser = HtmlParser::new();
6400 parser
6401 .component_registry
6402 .register_component(ComponentRegistration::new(
6403 "custom-element",
6404 r#"<template shadowrootmode="open" @click={foo} @click={bar}><div>content</div></template>"#,
6405 None,
6406 true,
6407 ))
6408 .expect("register");
6409 let result = parser.parse("index.html", "<custom-element>Hello</custom-element>");
6410 assert!(result.is_ok());
6411 let records = parser.into_fragment_records();
6412
6413 assert_stream!(
6414 records,
6415 "custom-element",
6416 [
6417 raw(r#"<template shadowrootmode="open">"#),
6418 structural_matcher("shadow_styles:custom-element"),
6419 raw("<div>content</div></template>"),
6420 ]
6421 );
6422 }
6423
6424 #[test]
6425 fn test_component_with_slots_and_attrs() {
6426 let mut parser = HtmlParser::new();
6427 parser
6428 .component_registry
6429 .register_component(ComponentRegistration::new(
6430 "custom-element",
6431 r#"<template shadowrootmode="open"><slot></slot></template>"#,
6432 None,
6433 true,
6434 ))
6435 .expect("register");
6436 let result = parser.parse(
6437 "index.html",
6438 r#"<custom-element appearance="subtle">Hello World</custom-element>"#,
6439 );
6440 assert!(result.is_ok());
6441 let records = parser.into_fragment_records();
6442 assert_fragments!(
6443 records["index.html"].fragments,
6444 [
6445 raw("<custom-element"),
6446 attr_raw_start("appearance", "subtle"),
6447 structural_matcher("streaming_root:custom-element"),
6448 raw(">"),
6449 component("custom-element"),
6450 raw("Hello World</custom-element>"),
6451 ]
6452 );
6453 }
6454
6455 #[test]
6456 fn test_component_legacy_no_styles() {
6457 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6458 parser
6459 .component_registry
6460 .register_component(ComponentRegistration::new(
6461 "custom-element",
6462 "<div>Custom Element</div>",
6463 None,
6464 true,
6465 ))
6466 .expect("register");
6467 let result = parser.parse("index.html", "<custom-element></custom-element>");
6468 assert!(result.is_ok());
6469 let records = parser.into_fragment_records();
6470
6471 assert_fragments!(
6472 records["index.html"].fragments,
6473 [
6474 raw("<custom-element"),
6475 structural_matcher("streaming_root:custom-element"),
6476 raw(">"),
6477 component("custom-element"),
6478 raw("</custom-element>"),
6479 ]
6480 );
6481
6482 assert_stream!(
6483 records,
6484 "custom-element",
6485 [raw("<div>Custom Element</div>"),]
6486 );
6487 }
6488
6489 #[test]
6490 fn test_component_self_closing() {
6491 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6492 parser
6493 .component_registry
6494 .register_component(ComponentRegistration::new(
6495 "custom-widget",
6496 "<div>Widget Content</div>",
6497 None,
6498 true,
6499 ))
6500 .expect("register");
6501 let result = parser.parse("index.html", r#"<custom-widget config="{{settings}}" />"#);
6502 assert!(result.is_ok());
6503 let records = parser.into_fragment_records();
6504
6505 assert_fragments!(
6506 records["index.html"].fragments,
6507 [
6508 raw("<custom-widget"),
6509 attr_start("config", "settings"),
6510 structural_matcher("streaming_root:custom-widget"),
6511 raw("/>"),
6512 component("custom-widget"),
6513 ]
6514 );
6515 }
6516
6517 #[test]
6518 fn test_component_nested_self_closing_in_slot() {
6519 let mut parser = HtmlParser::new();
6520 parser
6521 .component_registry
6522 .register_component(ComponentRegistration::new(
6523 "custom-icon",
6524 "<template shadowrootmode=\"open\"><svg><slot></slot></svg></template>",
6525 None,
6526 true,
6527 ))
6528 .expect("register");
6529 let result = parser.parse(
6530 "index.html",
6531 r##"<custom-icon><use href="#icon-{{iconName}}" /></custom-icon>"##,
6532 );
6533 assert!(result.is_ok());
6534 let records = parser.into_fragment_records();
6535
6536 assert_fragments!(
6537 records["index.html"].fragments,
6538 [
6539 raw("<custom-icon"),
6540 structural_matcher("streaming_root:custom-icon"),
6541 raw(">"),
6542 component("custom-icon"),
6543 raw("<use"),
6544 attr_template("href", "attr-1"),
6545 raw("/></custom-icon>"),
6546 ]
6547 );
6548
6549 assert_stream!(
6550 records,
6551 "custom-icon",
6552 [
6553 raw("<template shadowrootmode=\"open\">"),
6554 structural_matcher("shadow_styles:custom-icon"),
6555 raw("<svg><slot></slot></svg></template>"),
6556 ]
6557 );
6558 }
6559
6560 #[test]
6561 fn test_component_leading_boolean_attr_start() {
6562 let mut parser = HtmlParser::new();
6563 parser
6564 .component_registry
6565 .register_component(ComponentRegistration::new(
6566 "custom-element",
6567 r#"<template shadowrootmode="open"><slot></slot></template>"#,
6568 None,
6569 true,
6570 ))
6571 .expect("register");
6572 let result = parser.parse(
6573 "index.html",
6574 r#"<custom-element ?disabled="{{isDisabled}}" title="Hello"></custom-element>"#,
6575 );
6576 assert!(result.is_ok());
6577 let records = parser.into_fragment_records();
6578
6579 assert_fragments!(
6580 records["index.html"].fragments,
6581 [
6582 raw("<custom-element"),
6583 bool_attr_start("disabled", "isDisabled"),
6585 attr_raw("title", "Hello"),
6587 structural_matcher("streaming_root:custom-element"),
6588 raw(">"),
6589 component("custom-element"),
6590 raw("</custom-element>"),
6591 ]
6592 );
6593 }
6594
6595 #[test]
6596 fn test_component_boolean_predicate_preserves_condition_tree() {
6597 let mut parser = HtmlParser::new();
6598 parser
6599 .component_registry
6600 .register_component(ComponentRegistration::new(
6601 "custom-element",
6602 r#"<template shadowrootmode="open"><slot></slot></template>"#,
6603 None,
6604 true,
6605 ))
6606 .expect("register");
6607 let result = parser.parse(
6608 "index.html",
6609 r#"<custom-element ?disabled="{{page == 'dashboard'}}"></custom-element>"#,
6610 );
6611 assert!(result.is_ok());
6612
6613 let records = parser.into_fragment_records();
6614 let fragments = &records["index.html"].fragments;
6615 match fragments
6616 .get(1)
6617 .and_then(|fragment| fragment.fragment.as_ref())
6618 {
6619 Some(webui_protocol::web_ui_fragment::Fragment::Attribute(attr)) => {
6620 assert_eq!(attr.name, "disabled");
6621 assert!(attr.attr_start);
6622 match attr
6623 .condition_tree
6624 .as_ref()
6625 .and_then(|condition| condition.expr.as_ref())
6626 {
6627 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
6628 assert_eq!(pred.left, "page");
6629 assert_eq!(pred.operator, 3);
6630 assert_eq!(pred.right, "'dashboard'");
6631 }
6632 other => panic!("expected predicate condition tree, got {:?}", other),
6633 }
6634 }
6635 other => panic!("expected attribute fragment, got {:?}", other),
6636 }
6637 }
6638
6639 #[test]
6640 fn test_component_meta_link_tags() {
6641 let (fragments, _) = parse_and_get_fragments(
6642 r#"<head><meta charset="utf-8" /><link rel="stylesheet" href="{{cssFile}}" /></head>"#,
6643 );
6644 assert!(fragments.len() >= 7);
6645 assert!(
6646 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value == "<head>")
6647 );
6648 assert!(
6649 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
6650 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
6651 );
6652 assert!(
6653 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<meta charset=\"utf-8\"") && raw.value.contains("<link"))
6654 );
6655 assert!(
6656 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Attribute(a)) if a.name == "href" && a.value == "cssFile")
6657 );
6658 assert!(
6659 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("/>"))
6660 );
6661 assert!(
6662 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
6663 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
6664 );
6665 assert!(
6666 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</head>"))
6667 );
6668 }
6669
6670 #[test]
6671 fn test_nested_directives() {
6672 let mut parser = HtmlParser::new();
6673 let html = r#"<for each="category in categories">
6674 <if condition="category.hasItems">
6675 <for each="item in category.items">
6676 {{item.title}}
6677 </for>
6678 </if>
6679 </for>"#;
6680
6681 let result = parser.parse("test.html", html);
6682
6683 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6684 let fragment_records = parser.into_fragment_records();
6685
6686 assert_stream!(
6687 fragment_records,
6688 "test.html",
6689 [for_loop("category", "categories", "for-1"),]
6690 );
6691
6692 assert_stream!(fragment_records, "for-1", [if_cond("if-1"),]);
6693
6694 assert_stream!(
6695 fragment_records,
6696 "if-1",
6697 [for_loop("item", "category.items", "for-2"),]
6698 );
6699
6700 assert_stream!(fragment_records, "for-2", [signal("item.title"),]);
6701 }
6702
6703 #[test]
6704 fn test_complex_directives() {
6705 let mut parser = HtmlParser::new();
6706 let html = r#"<for each="category in categories">
6707 <div class="category">
6708 <h2>{{category.name}}</h2>
6709 <if condition="category.hasItems">
6710 <ul>
6711 <for each="item in category.items">
6712 <li>{{item.title}}</li>
6713 </for>
6714 </ul>
6715 </if>
6716 </div>
6717 </for>"#;
6718
6719 let result = parser.parse("test.html", html);
6720
6721 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6722 let fragment_records = parser.into_fragment_records();
6723
6724 assert_stream!(
6725 fragment_records,
6726 "test.html",
6727 [for_loop("category", "categories", "for-1"),]
6728 );
6729
6730 assert_stream!(
6732 fragment_records,
6733 "for-1",
6734 [
6735 raw("<div class=\"category\"><h2>"),
6736 signal("category.name"),
6737 raw("</h2>"),
6738 if_cond("if-1"),
6739 raw("</div>"),
6740 ]
6741 );
6742
6743 assert_stream!(
6745 fragment_records,
6746 "if-1",
6747 [
6748 raw("<ul>"),
6749 for_loop("item", "category.items", "for-2"),
6750 raw("</ul>"),
6751 ]
6752 );
6753
6754 assert_stream!(
6756 fragment_records,
6757 "for-2",
6758 [raw("<li>"), signal("item.title"), raw("</li>"),]
6759 );
6760 }
6761
6762 fn parse_and_get_fragments(html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6766 let mut parser = HtmlParser::new();
6767 let result = parser.parse("index.html", html);
6768 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6769 let records = parser.into_fragment_records();
6770 let fragments = records
6771 .get("index.html")
6772 .expect("Failed to get index.html fragment")
6773 .fragments
6774 .clone();
6775 (fragments, records)
6776 }
6777
6778 fn parse_with_component(tag: &str, html: &str) -> (Vec<WebUIFragment>, WebUIFragmentRecords) {
6780 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6781 parser
6782 .component_registry
6783 .register_component(ComponentRegistration::new(tag, "<div></div>", None, true))
6784 .expect("register");
6785 let result = parser.parse("index.html", html);
6786 assert!(result.is_ok(), "Parse error: {:?}", result.err());
6787 let records = parser.into_fragment_records();
6788 let fragments = records
6789 .get("index.html")
6790 .expect("Failed to get index.html fragment")
6791 .fragments
6792 .clone();
6793 (fragments, records)
6794 }
6795
6796 #[test]
6797 fn test_attribute_handlebars_in_href() {
6798 let (fragments, _) = parse_and_get_fragments(r#"<a href="{{url}}">{{name}}</a>"#);
6800 assert_fragments!(
6801 fragments,
6802 [
6803 raw("<a"),
6804 attr("href", "url"),
6805 raw(">"),
6806 signal("name"),
6807 raw("</a>"),
6808 ]
6809 );
6810 }
6811
6812 #[test]
6813 fn test_attribute_boolean_with_handlebars() {
6814 let (fragments, _) =
6816 parse_and_get_fragments("<button ?disabled={{isDisabled}}>Click</button>");
6817 assert_fragments!(
6818 fragments,
6819 [
6820 raw("<button"),
6821 bool_attr("disabled", "isDisabled"),
6822 raw(">Click</button>"),
6823 ]
6824 );
6825 }
6826
6827 #[test]
6828 fn test_attribute_multiple_boolean() {
6829 let (fragments, _) =
6832 parse_and_get_fragments("<input ?checked={{isChecked}} ?disabled={{isDisabled}} />");
6833
6834 assert_fragments!(
6835 fragments,
6836 [
6837 raw("<input"),
6838 bool_attr("checked", "isChecked"),
6839 bool_attr("disabled", "isDisabled"),
6840 raw("/>"),
6841 ]
6842 );
6843 }
6844
6845 #[test]
6846 fn test_attribute_boolean_and_regular_together() {
6847 let (fragments, _) = parse_and_get_fragments(
6850 r#"<button ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6851 );
6852
6853 assert_fragments!(
6854 fragments,
6855 [
6856 raw("<button"),
6857 bool_attr("disabled", "isDisabled"),
6858 raw(" type=\"button\">Hi</button>"),
6859 ]
6860 );
6861 }
6862
6863 #[test]
6864 fn test_attribute_boolean_sandwiched() {
6865 let (fragments, _) = parse_and_get_fragments(
6868 r#"<button version={{edition}} ?disabled="{{isDisabled}}" type="button">Hi</button>"#,
6869 );
6870
6871 assert_fragments!(
6872 fragments,
6873 [
6874 raw("<button"),
6875 attr("version", "edition"),
6876 bool_attr("disabled", "isDisabled"),
6877 raw(" type=\"button\">Hi</button>"),
6878 ]
6879 );
6880 }
6881
6882 #[test]
6883 fn test_attribute_boolean_ending() {
6884 let (fragments, _) = parse_and_get_fragments(
6887 r#"<button version={{edition}} ?disabled="{{isDisabled}}">Hi</button>"#,
6888 );
6889
6890 assert_fragments!(
6891 fragments,
6892 [
6893 raw("<button"),
6894 attr("version", "edition"),
6895 bool_attr("disabled", "isDisabled"),
6896 raw(">Hi</button>"),
6897 ]
6898 );
6899 }
6900
6901 #[test]
6902 fn test_attribute_boolean_dotted_path() {
6903 let (fragments, _) =
6906 parse_and_get_fragments("<div ?checked={{layout.isPinned}}>Content</div>");
6907
6908 assert_fragments!(
6909 fragments,
6910 [
6911 raw("<div"),
6912 bool_attr("checked", "layout.isPinned"),
6913 raw(">Content</div>"),
6914 ]
6915 );
6916 }
6917
6918 #[test]
6919 fn test_attribute_colon_prefixed_complex() {
6920 let (fragments, _) = parse_with_component(
6923 "my-component",
6924 r#"<my-component :config="{{settings}}"></my-component>"#,
6925 );
6926
6927 assert_fragments!(
6928 fragments,
6929 [
6930 raw("<my-component"),
6931 attr_complex_start(":config", "settings"),
6932 structural_matcher("streaming_root:my-component"),
6933 raw(">"),
6934 component("my-component"),
6935 raw("</my-component>"),
6936 ]
6937 );
6938 }
6939
6940 #[test]
6941 fn test_attribute_multiple_colon_prefixed() {
6942 let (fragments, _) = parse_with_component(
6945 "my-component",
6946 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"#,
6947 );
6948
6949 assert_fragments!(
6950 fragments,
6951 [
6952 raw("<my-component"),
6953 attr_complex_start(":prop1", "val1"),
6954 attr_complex(":prop2", "val2"),
6955 structural_matcher("streaming_root:my-component"),
6956 raw(">"),
6957 component("my-component"),
6958 raw("</my-component>"),
6959 ]
6960 );
6961 }
6962
6963 #[test]
6964 fn test_blocked_complex_property_innerhtml() {
6965 let mut parser = HtmlParser::new();
6966 let result = parser.parse("index.html", r#"<div :innerHTML="{{content}}"></div>"#);
6967 assert!(
6968 result.is_err(),
6969 "Expected error for :innerHTML on native element"
6970 );
6971 let err = result.unwrap_err().to_string();
6972 assert!(
6973 err.contains("only allowed on custom elements"),
6974 "Error: {err}"
6975 );
6976 }
6977
6978 #[test]
6979 fn test_blocked_complex_on_native_element() {
6980 let mut parser = HtmlParser::new();
6981 let result = parser.parse("index.html", r#"<div :data="{{config}}"></div>"#);
6982 assert!(
6983 result.is_err(),
6984 "Expected error for :data on native element"
6985 );
6986 let err = result.unwrap_err().to_string();
6987 assert!(
6988 err.contains("only allowed on custom elements"),
6989 "Error: {err}"
6990 );
6991 }
6992
6993 #[test]
6994 fn test_blocked_complex_property_on_component() {
6995 let mut parser = HtmlParser::new();
6996 parser
6997 .component_registry
6998 .register_component(ComponentRegistration::new(
6999 "my-widget",
7000 "<div></div>",
7001 None,
7002 true,
7003 ))
7004 .expect("register");
7005 let result = parser.parse(
7006 "index.html",
7007 r#"<my-widget :innerHTML="{{html}}"></my-widget>"#,
7008 );
7009 assert!(
7010 result.is_err(),
7011 "Expected error for :innerHTML on component"
7012 );
7013 let err = result.unwrap_err().to_string();
7014 assert!(err.contains("HTML injection"), "Error: {err}");
7015 }
7016
7017 #[test]
7018 fn test_allowed_complex_property() {
7019 let (fragments, _) = parse_with_component(
7021 "my-component",
7022 r#"<my-component :config="{{settings}}"></my-component>"#,
7023 );
7024 assert_fragments!(
7025 fragments,
7026 [
7027 raw("<my-component"),
7028 attr_complex_start(":config", "settings"),
7029 structural_matcher("streaming_root:my-component"),
7030 raw(">"),
7031 component("my-component"),
7032 raw("</my-component>"),
7033 ]
7034 );
7035 }
7036
7037 #[test]
7038 fn test_attribute_mixed_normal_boolean_colon() {
7039 let (fragments, _) = parse_with_component(
7042 "my-component",
7043 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"#,
7044 );
7045
7046 assert_fragments!(
7047 fragments,
7048 [
7049 raw("<my-component"),
7050 attr_raw_start("id", "comp"),
7051 attr_complex(":config", "settings"),
7052 bool_attr("enabled", "isEnabled"),
7053 structural_matcher("streaming_root:my-component"),
7054 raw(">"),
7055 component("my-component"),
7056 raw("</my-component>"),
7057 ]
7058 );
7059 }
7060
7061 #[test]
7062 fn test_attribute_reject_boolean_without_handlebars() {
7063 let (fragments, _) = parse_and_get_fragments(r#"<button ?checked="name"></button>"#);
7066
7067 assert_fragments!(fragments, [raw("<button></button>"),]);
7069 }
7070
7071 #[test]
7072 fn test_attribute_reject_boolean_with_partial_handlebars() {
7073 let (fragments, _) =
7076 parse_and_get_fragments(r#"<button ?checked="Hello {{name}}"></button>"#);
7077
7078 assert_fragments!(fragments, [raw("<button></button>"),]);
7080 }
7081
7082 #[test]
7083 fn test_attribute_reject_boolean_with_plain_value() {
7084 let (fragments, _) = parse_and_get_fragments(r#"<button ?disabled="true">Click</button>"#);
7087
7088 assert_fragments!(fragments, [raw("<button>Click</button>"),]);
7090 }
7091
7092 #[test]
7093 fn test_attribute_boolean_predicate_equal() {
7094 let (fragments, _) =
7096 parse_and_get_fragments(r#"<div ?data-active="{{page == 'dashboard'}}">X</div>"#);
7097 assert_fragments!(
7098 fragments,
7099 [
7100 raw("<div"),
7101 bool_attr_predicate("data-active", "page", 3, "'dashboard'"),
7102 raw(">X</div>"),
7103 ]
7104 );
7105 }
7106
7107 #[test]
7108 fn test_attribute_boolean_predicate_greater_than() {
7109 let (fragments, _) = parse_and_get_fragments(r#"<span ?hidden="{{num > 9}}">X</span>"#);
7111 assert_fragments!(
7112 fragments,
7113 [
7114 raw("<span"),
7115 bool_attr_predicate("hidden", "num", 1, "9"),
7116 raw(">X</span>"),
7117 ]
7118 );
7119 }
7120
7121 #[test]
7122 fn test_attribute_boolean_predicate_not_equal() {
7123 let (fragments, _) =
7125 parse_and_get_fragments(r#"<a ?data-active="{{status != 'inactive'}}">X</a>"#);
7126 assert_fragments!(
7127 fragments,
7128 [
7129 raw("<a"),
7130 bool_attr_predicate("data-active", "status", 4, "'inactive'"),
7131 raw(">X</a>"),
7132 ]
7133 );
7134 }
7135
7136 #[test]
7137 fn test_attribute_boolean_negation() {
7138 let (fragments, _) =
7140 parse_and_get_fragments(r#"<button ?disabled="{{!isReady}}">X</button>"#);
7141 assert_fragments!(
7142 fragments,
7143 [
7144 raw("<button"),
7145 bool_attr_not("disabled", "isReady"),
7146 raw(">X</button>"),
7147 ]
7148 );
7149 }
7150
7151 #[test]
7152 fn test_attribute_mixed_static_dynamic() {
7153 let (fragments, records) =
7156 parse_and_get_fragments(r#"<textarea value="hello {{world}}">Hi</textarea>"#);
7157
7158 assert_fragments!(
7159 fragments,
7160 [
7161 raw("<textarea"),
7162 attr_template("value", "attr-1"),
7163 raw(">Hi</textarea>"),
7164 ]
7165 );
7166
7167 assert_stream!(records, "attr-1", [raw("hello "), signal("world"),]);
7169 }
7170
7171 #[test]
7174 fn test_body_signals() {
7175 let (fragments, _) = parse_and_get_fragments("<body><app-shell></app-shell></body>");
7176 assert_fragments!(
7177 fragments,
7178 [
7179 raw("<body>"),
7180 structural_matcher("body_start"),
7181 raw("<app-shell></app-shell>"),
7182 structural_matcher("body_end"),
7183 raw("</body>"),
7184 ]
7185 );
7186 }
7187
7188 #[test]
7191 fn test_empty_for_produces_nothing() {
7192 let (fragments, records) =
7193 parse_and_get_fragments(r#"<div><for each="item in items"></for></div>"#);
7194 assert_fragments!(fragments, [raw("<div></div>"),]);
7195 assert!(!records.contains_key("for-1"));
7196 }
7197
7198 #[test]
7201 fn test_self_closing_svg_path() {
7202 let (fragments, _) =
7203 parse_and_get_fragments(r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#);
7204 assert_fragments!(
7205 fragments,
7206 [raw(
7207 r#"<svg width="19"><path d="foo" fill="currentcolor"/></svg>"#
7208 ),]
7209 );
7210 }
7211
7212 #[test]
7213 fn test_html5_void_elements() {
7214 let (fragments, _) = parse_and_get_fragments(
7215 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#,
7216 );
7217 assert_fragments!(
7218 fragments,
7219 [raw(
7220 r#"<div><img src="test.jpg" alt="test"><br><hr><input type="text"></div>"#
7221 ),]
7222 );
7223 }
7224
7225 #[test]
7226 fn test_self_closing_with_dynamic_attributes() {
7227 let (fragments, _) =
7228 parse_and_get_fragments(r#"<img src="{{imageUrl}}" alt="{{imageAlt}}" />"#);
7229 assert_fragments!(
7230 fragments,
7231 [
7232 raw("<img"),
7233 attr("src", "imageUrl"),
7234 attr("alt", "imageAlt"),
7235 raw("/>"),
7236 ]
7237 );
7238 }
7239
7240 #[test]
7241 fn test_self_closing_with_boolean_attributes() {
7242 let (fragments, _) = parse_and_get_fragments(
7243 r#"<input type="checkbox" ?checked="{{isSelected}}" ?disabled="{{isDisabled}}" />"#,
7244 );
7245 assert_fragments!(
7246 fragments,
7247 [
7248 raw("<input type=\"checkbox\""),
7249 bool_attr("checked", "isSelected"),
7250 bool_attr("disabled", "isDisabled"),
7251 raw("/>"),
7252 ]
7253 );
7254 }
7255
7256 #[test]
7257 fn test_multiple_self_closing_in_sequence() {
7258 let (fragments, _) =
7259 parse_and_get_fragments(r#"<img src="1.jpg" /><br /><img src="2.jpg" />"#);
7260 assert_fragments!(
7261 fragments,
7262 [raw(r#"<img src="1.jpg"/><br/><img src="2.jpg"/>"#),]
7263 );
7264 }
7265
7266 #[test]
7267 fn test_self_closing_with_mixed_content() {
7268 let (fragments, _) =
7269 parse_and_get_fragments(r#"<div>Text before<img src="{{url}}" />Text after</div>"#);
7270 assert_fragments!(
7271 fragments,
7272 [
7273 raw("<div>Text before<img"),
7274 attr("src", "url"),
7275 raw("/>Text after</div>"),
7276 ]
7277 );
7278 }
7279
7280 #[test]
7281 fn test_self_closing_svg_elements() {
7282 let (fragments, _) = parse_and_get_fragments(
7283 r#"<svg><circle cx="{{x}}" cy="{{y}}" r="5" /><rect width="10" height="10" /></svg>"#,
7284 );
7285 assert_fragments!(
7286 fragments,
7287 [
7288 raw("<svg><circle"),
7289 attr("cx", "x"),
7290 attr("cy", "y"),
7291 raw(r#" r="5"/><rect width="10" height="10"/></svg>"#),
7292 ]
7293 );
7294 }
7295
7296 #[test]
7297 fn test_self_closing_inside_for_loop() {
7298 let (fragments, records) = parse_and_get_fragments(
7299 r#"<for each="item in items"><img src="{{item.url}}" /></for>"#,
7300 );
7301 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7302 assert_stream!(
7303 records,
7304 "for-1",
7305 [raw("<img"), attr("src", "item.url"), raw("/>"),]
7306 );
7307 }
7308
7309 #[test]
7310 fn test_self_closing_whitespace_variations() {
7311 let (fragments, _) =
7312 parse_and_get_fragments(r#"<img src="test.jpg"/><input type="text" /><br/>"#);
7313 assert_fragments!(
7314 fragments,
7315 [raw(r#"<img src="test.jpg"/><input type="text"/><br/>"#),]
7316 );
7317 }
7318
7319 #[test]
7320 fn test_deeply_nested_self_closing() {
7321 let (fragments, _) = parse_and_get_fragments(
7322 r#"<div><section><article><img src="deep.jpg" /><br /></article></section></div>"#,
7323 );
7324 assert_fragments!(
7325 fragments,
7326 [raw(
7327 r#"<div><section><article><img src="deep.jpg"/><br/></article></section></div>"#
7328 ),]
7329 );
7330 }
7331
7332 #[test]
7333 fn test_self_closing_vs_empty_regular_tags() {
7334 let (fragments, _) =
7335 parse_and_get_fragments(r#"<div></div><img src="test.jpg" /><span></span>"#);
7336 assert_fragments!(
7337 fragments,
7338 [raw(r#"<div></div><img src="test.jpg"/><span></span>"#),]
7339 );
7340 }
7341
7342 #[test]
7345 fn test_for_custom_id_attribute() {
7346 let (fragments, records) = parse_and_get_fragments(
7347 r#"<for each="item in items" id="static"><span>Item</span></for>"#,
7348 );
7349 assert_fragments!(fragments, [for_loop("item", "items", "static-1"),]);
7350 assert_stream!(records, "static-1", [raw("<span>Item</span>"),]);
7351 }
7352
7353 #[test]
7354 fn test_for_recursive_template() {
7355 let mut parser = HtmlParser::new();
7356 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>"#;
7357 let result = parser.parse("index.html", html);
7358 assert!(result.is_ok(), "Parse error: {:?}", result.err());
7359 let records = parser.into_fragment_records();
7360
7361 assert_fragments!(
7362 records["index.html"].fragments,
7363 [for_loop("item", "items", "static-1"),]
7364 );
7365
7366 assert_stream!(
7367 records,
7368 "static-1",
7369 [
7370 raw("<div><span>"),
7371 signal("item.name"),
7372 raw("</span>"),
7373 for_loop("item", "item.children", "static-1"),
7374 raw("</div>"),
7375 ]
7376 );
7377 }
7378
7379 #[test]
7380 fn each_accepts_only_direct_item_in_collection_syntax() {
7381 assert_eq!(
7382 parse_for_each("test-tree", "child in items").unwrap(),
7383 ("child", "items")
7384 );
7385 assert_eq!(
7386 parse_for_each("test-tree", " \tchild \n in \r child.children ").unwrap(),
7387 ("child", "child.children")
7388 );
7389 }
7390
7391 #[test]
7392 fn braced_each_is_rejected_by_server_and_client_compilation() {
7393 use crate::plugin::webui::generate_compiled_template;
7394
7395 for source in [
7396 r#"<for each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7397 r#"<for id="tree" each="{{child in items}}"><span>{{child.name}}</span></for>"#,
7398 r#"<for id="tree" each="child in items"><for id="tree" each="{{child in child.children}}" /></for>"#,
7399 r#"<for id="tree" each="{{ child in items }}"><span>{{child.name}}</span></for>"#,
7400 r#"<for id="tree" each="{child in items}"><span>{{child.name}}</span></for>"#,
7401 ] {
7402 let server = HtmlParser::new().parse("test-tree", source).unwrap_err();
7403 let client = generate_compiled_template("test-tree", source).unwrap_err();
7404 let ParserError::Template(server) = server else {
7405 panic!("expected a server authoring diagnostic");
7406 };
7407 let ParserError::Template(client) = client else {
7408 panic!("expected a client authoring diagnostic");
7409 };
7410 assert_eq!(server.error_code(), client.error_code(), "{source}");
7411 assert!(matches!(
7412 server.error_code(),
7413 Some(codes::INVALID_FOR_EACH | codes::INVALID_FOR_IDENTIFIER)
7414 ));
7415 assert!(server.position_line_column().is_some());
7416 assert!(server.help_text().unwrap().contains("without braces"));
7417 assert!(client.help_text().unwrap().contains("without braces"));
7418 }
7419 }
7420
7421 #[test]
7422 fn named_for_recursion_reuses_one_scoped_record() {
7423 let mut parser = HtmlParser::new();
7424 parser
7425 .parse(
7426 "file-a.html",
7427 r#"<for id="tree-item" each="child in items"><li>{{child.name}}</li><for id="tree-item" each="child in child.children" /></for>"#,
7428 )
7429 .unwrap();
7430 let records = parser.into_fragment_records();
7431 assert_eq!(records.len(), 2);
7432 assert_stream!(
7433 records,
7434 "file-a.html",
7435 [for_loop("child", "items", "tree-item-1")]
7436 );
7437 assert_stream!(
7438 records,
7439 "tree-item-1",
7440 [
7441 raw("<li>"),
7442 signal("child.name"),
7443 raw("</li>"),
7444 for_loop("child", "child.children", "tree-item-1"),
7445 ]
7446 );
7447 }
7448
7449 #[test]
7450 fn named_for_forward_references_and_empty_definitions() {
7451 let mut parser = HtmlParser::new();
7452 parser
7453 .parse(
7454 "file-a.html",
7455 r#"<for id="loop" each="item in earlier" /><for id="loop" each="item in later"></for>"#,
7456 )
7457 .unwrap();
7458 let records = parser.into_fragment_records();
7459 assert_stream!(
7460 records,
7461 "file-a.html",
7462 [
7463 for_loop("item", "earlier", "loop-1"),
7464 for_loop("item", "later", "loop-1")
7465 ]
7466 );
7467 assert!(records["loop-1"].fragments.is_empty());
7468 }
7469
7470 #[test]
7471 fn named_for_scope_survives_component_parsing_and_reparsing() {
7472 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7473 parser
7474 .component_registry_mut()
7475 .register_component(ComponentRegistration::new(
7476 "other-tree",
7477 r#"<for id="loop" each="item in other"><b>{{item.label}}</b></for>"#,
7478 None,
7479 false,
7480 ))
7481 .unwrap();
7482 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>"#;
7483 parser.parse("file-a.html", source).unwrap();
7484 parser
7485 .parse(
7486 "file-b.html",
7487 r#"<for id="loop" each="item in items">B</for>"#,
7488 )
7489 .unwrap();
7490 parser.parse("file-a.html", source).unwrap();
7491 let records = parser.into_fragment_records();
7492 assert_stream!(
7493 records,
7494 "file-a.html",
7495 [for_loop("item", "items", "loop-1")]
7496 );
7497 assert_stream!(records, "other-tree", [for_loop("item", "other", "loop-2")]);
7498 assert_stream!(
7499 records,
7500 "file-b.html",
7501 [for_loop("item", "items", "loop-3")]
7502 );
7503 assert_stream!(records, "loop-3", [raw("B")]);
7504 assert!(records["loop-1"].fragments.iter().any(|fragment| {
7505 matches!(fragment.fragment.as_ref(), Some(Fragment::ForLoop(repeat)) if repeat.fragment_id == "loop-1")
7506 }));
7507 }
7508
7509 #[test]
7510 fn named_for_reports_authoring_errors() {
7511 for (source, code) in [
7512 (r#"<for id each="x in xs" />"#, codes::INVALID_FOR_ID),
7513 (r#"<for id="" each="x in xs" />"#, codes::INVALID_FOR_ID),
7514 (
7515 r#"<for id="{{name}}" each="x in xs" />"#,
7516 codes::INVALID_FOR_ID,
7517 ),
7518 (
7519 r#"<for id="tree" template="tree" each="x in xs" />"#,
7520 codes::INVALID_FOR_ID,
7521 ),
7522 (
7523 r#"<for id="tree" id="tree" each="x in xs" />"#,
7524 codes::INVALID_FOR_ID,
7525 ),
7526 (r#"<for id="tree" each="x in xs" />"#, codes::UNKNOWN_FOR_ID),
7527 (
7528 r#"<for id="tree" each="x in xs">a</for><for id="tree" each="x in ys">b</for>"#,
7529 codes::DUPLICATE_FOR_ID,
7530 ),
7531 (
7532 r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children"><b>{{y.name}}</b></for></for>"#,
7533 codes::DUPLICATE_FOR_ID,
7534 ),
7535 (
7536 r#"<for id="tree" each="x in xs"><for id="tree" each="y in x.children" /></for>"#,
7537 codes::INCOMPATIBLE_FOR_ITEM,
7538 ),
7539 ] {
7540 let error = HtmlParser::new().parse("file-a.html", source).unwrap_err();
7541 let ParserError::Template(diagnostic) = error else {
7542 panic!("expected structured diagnostic for {source}");
7543 };
7544 assert_eq!(diagnostic.error_code(), Some(code), "{source}");
7545 assert!(!diagnostic.to_string().contains('\u{1b}'));
7546 assert!(diagnostic.to_string().contains("help:"));
7547 assert!(diagnostic.to_string().contains("file-a.html"));
7548 assert!(diagnostic.position_line_column().is_some(), "{source}");
7549 }
7550 }
7551
7552 #[test]
7553 fn named_for_cannot_reference_a_definition_from_another_file() {
7554 let mut parser = HtmlParser::new();
7555 parser
7556 .parse("a.html", r#"<for id="tree" each="x in xs">a</for>"#)
7557 .unwrap();
7558 let error = parser
7559 .parse("b.html", r#"<for id="tree" each="x in xs" />"#)
7560 .unwrap_err();
7561 assert!(
7562 matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::UNKNOWN_FOR_ID))
7563 );
7564 }
7565
7566 #[test]
7567 fn named_for_does_not_overwrite_generated_fragments() {
7568 let mut parser = HtmlParser::new();
7569 parser
7570 .parse(
7571 "a.html",
7572 r#"<for id="for" each="x in xs">named</for><for each="x in xs">ordinary</for>"#,
7573 )
7574 .unwrap();
7575 let records = parser.into_fragment_records();
7576 assert_stream!(records, "for-1", [raw("named")]);
7577 assert_stream!(records, "for-2", [raw("ordinary")]);
7578 let error = HtmlParser::new()
7579 .parse(
7580 "a.html",
7581 r#"<for each="x in xs">ordinary</for><for id="for" each="x in xs">named</for>"#,
7582 )
7583 .unwrap_err();
7584 assert!(
7585 matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::INVALID_FOR_ID))
7586 );
7587 }
7588
7589 #[test]
7590 fn named_for_css_analysis_terminates_without_losing_sibling_scopes() {
7591 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7592 for (name, source, css) in [
7593 ("token-leaf", "<b>leaf</b>", "b { color: var(--accent); }"),
7594 (
7595 "defined-tree",
7596 r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7597 ":root { --accent: red; }",
7598 ),
7599 (
7600 "undefined-tree",
7601 r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children" /></for>"#,
7602 "",
7603 ),
7604 ] {
7605 parser
7606 .component_registry_mut()
7607 .register_component(ComponentRegistration::new(name, source, Some(css), false))
7608 .unwrap();
7609 }
7610 parser
7611 .parse(
7612 "index.html",
7613 "<defined-tree></defined-tree><undefined-tree></undefined-tree>",
7614 )
7615 .unwrap();
7616 let analysis = parser.token_analysis();
7617 assert_eq!(analysis.protocol_tokens, ["accent"]);
7618 assert_eq!(analysis.fallback_chains.len(), 1);
7619 }
7620
7621 #[test]
7622 fn named_for_css_analysis_deduplicates_equivalent_diamond_paths() {
7623 use std::fmt::Write;
7624
7625 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7626 parser
7627 .component_registry_mut()
7628 .register_component(ComponentRegistration::new(
7629 "token-leaf",
7630 "<b>leaf</b>",
7631 Some("b { color: var(--accent); }"),
7632 false,
7633 ))
7634 .unwrap();
7635 let mut source = String::with_capacity(12 * 150);
7636 for index in 0..12 {
7637 write!(source, r#"<for id="b{index}" each="item in items">"#).unwrap();
7638 if index < 11 {
7639 let next = index + 1;
7640 write!(source, r#"<for id="b{next}" each="item in item.children"/><for id="b{next}" each="item in item.children"/>"#).unwrap();
7641 } else {
7642 source.push_str("<token-leaf></token-leaf>");
7643 }
7644 source.push_str("</for>");
7645 }
7646 parser.parse("index.html", &source).unwrap();
7647 let analysis = parser.token_analysis();
7648 assert_eq!(analysis.protocol_tokens, ["accent"]);
7649 assert_eq!(analysis.fallback_chains.len(), 1);
7650 }
7651
7652 #[test]
7653 fn named_for_css_analysis_preserves_distinct_same_size_definition_sets() {
7654 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7655 for (name, source, css) in [
7656 (
7657 "token-leaf",
7658 "<b>leaf</b>",
7659 "b { color: var(--accent); background: var(--shade); }",
7660 ),
7661 (
7662 "shared-tree",
7663 r#"<for id="tree" each="x in xs"><token-leaf></token-leaf><for id="tree" each="x in x.children"/></for>"#,
7664 "",
7665 ),
7666 (
7667 "accent-tree",
7668 "<shared-tree></shared-tree>",
7669 ":root { --accent: red; }",
7670 ),
7671 (
7672 "shade-tree",
7673 "<shared-tree></shared-tree>",
7674 ":root { --shade: blue; }",
7675 ),
7676 ] {
7677 parser
7678 .component_registry_mut()
7679 .register_component(ComponentRegistration::new(name, source, Some(css), false))
7680 .unwrap();
7681 }
7682 parser
7683 .parse(
7684 "index.html",
7685 "<accent-tree></accent-tree><shade-tree></shade-tree>",
7686 )
7687 .unwrap();
7688 assert_eq!(parser.token_analysis().protocol_tokens, ["accent", "shade"]);
7689 }
7690
7691 #[test]
7692 fn legacy_for_template_attribute_is_rejected() {
7693 for source in [
7694 r#"<for template="legacy" each="x in xs"><span>{{x.name}}</span></for>"#,
7695 r#"<for template="legacy" each="x in xs" />"#,
7696 r#"<for template each="x in xs"></for>"#,
7697 r#"<for template="" each="x in xs"></for>"#,
7698 ] {
7699 let error = HtmlParser::new().parse("index.html", source).unwrap_err();
7700 let ParserError::Template(diagnostic) = error else {
7701 panic!("expected an authoring diagnostic for {source}");
7702 };
7703 assert_eq!(diagnostic.error_code(), Some(codes::INVALID_FOR_ID));
7704 assert!(diagnostic
7705 .help_text()
7706 .unwrap()
7707 .contains("replace template with id"));
7708 assert!(diagnostic.position_line_column().is_some());
7709 }
7710 }
7711
7712 #[test]
7713 fn named_for_recursion_still_rejects_reachable_boundaries() {
7714 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();
7715 assert!(
7716 matches!(error, ParserError::Template(diag) if diag.error_code() == Some(codes::BOUNDARY_IN_REPEAT))
7717 );
7718 }
7719
7720 #[test]
7721 fn named_for_fast_components_reject_references_but_keep_plain_loops() {
7722 use crate::plugin::{
7723 fast_v2::FastV2ParserPlugin, fast_v3::FastV3ParserPlugin, ParserPlugin,
7724 ParserPluginArtifacts,
7725 };
7726
7727 for fast_v2 in [true, false] {
7728 for references in [true, false] {
7729 let plugin: Box<dyn ParserPlugin> = if fast_v2 {
7730 Box::new(FastV2ParserPlugin::new())
7731 } else {
7732 Box::new(FastV3ParserPlugin::new())
7733 };
7734 let mut parser = HtmlParser::with_plugin(plugin);
7735 let body = if references {
7736 r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#
7737 } else {
7738 r#"<for id="tree" each="item in items"><if condition="{{item.name}}"><b>{{item.name}}</b></if></for>"#
7739 };
7740 parser
7741 .component_registry_mut()
7742 .register_component(ComponentRegistration::new("my-tree", body, None, true))
7743 .unwrap();
7744 let result = parser.parse("index.html", "<my-tree></my-tree>");
7745 if references {
7746 let error = result.unwrap_err();
7747 assert!(
7748 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())
7749 );
7750 } else {
7751 result.unwrap();
7752 let ParserPluginArtifacts::ComponentTemplates(templates) =
7753 parser.take_plugin_artifacts().unwrap()
7754 else {
7755 panic!("FAST produces component templates");
7756 };
7757 assert!(templates[0]
7758 .template
7759 .contains(r#"<f-repeat value="{{item in items}}">"#));
7760 assert!(templates[0]
7761 .template
7762 .contains(r#"<f-when value="{{item.name}}">"#));
7763 }
7764 }
7765 }
7766 }
7767
7768 #[test]
7771 fn test_if_multiple_children() {
7772 let (fragments, records) =
7774 parse_and_get_fragments(r#"<if condition="valid"><p>hello</p><p>world</p></if>"#);
7775 assert_fragments!(fragments, [if_cond("if-1"),]);
7776 assert_stream!(records, "if-1", [raw("<p>hello</p><p>world</p>"),]);
7777 }
7778
7779 #[test]
7780 fn if_condition_with_double_quoted_literal_round_trips_through_parser() {
7781 let (fragments, _) =
7786 parse_and_get_fragments(r#"<if condition='status == "ready"'><span>ok</span></if>"#);
7787 let condition = fragments
7788 .iter()
7789 .find_map(|fragment| match fragment.fragment.as_ref() {
7790 Some(webui_protocol::web_ui_fragment::Fragment::IfCond(if_cond)) => {
7791 if_cond.condition.as_ref()
7792 }
7793 _ => None,
7794 })
7795 .expect("if-condition fragment with a parsed condition");
7796 match condition.expr.as_ref() {
7797 Some(webui_protocol::condition_expr::Expr::Predicate(pred)) => {
7798 assert_eq!(pred.left, "status");
7799 assert_eq!(pred.right, "\"ready\"");
7800 }
7801 other => panic!("expected a predicate condition, got {other:?}"),
7802 }
7803 }
7804
7805 #[test]
7806 fn test_for_multiple_children() {
7807 let (fragments, records) =
7809 parse_and_get_fragments(r#"<for each="item in items"><p>hello</p><p>world</p></for>"#);
7810 assert_fragments!(fragments, [for_loop("item", "items", "for-1"),]);
7811 assert_stream!(records, "for-1", [raw("<p>hello</p><p>world</p>"),]);
7812 }
7813
7814 #[test]
7817 fn test_handlebars_at_beginning() {
7818 let (fragments, _) = parse_and_get_fragments("{{first}}");
7820 assert_fragments!(fragments, [signal("first"),]);
7821 }
7822
7823 #[test]
7824 fn test_handlebars_at_beginning_and_raw() {
7825 let (fragments, _) = parse_and_get_fragments("{{first}}test");
7827 assert_fragments!(fragments, [signal("first"), raw("test"),]);
7828 }
7829
7830 #[test]
7831 fn test_handlebars_raw_and_end() {
7832 let (fragments, _) = parse_and_get_fragments("test{{first}}");
7834 assert_fragments!(fragments, [raw("test"), signal("first"),]);
7835 }
7836
7837 #[test]
7840 fn test_handlebars_invalid_triple_open() {
7841 let (fragments, _) = parse_and_get_fragments("{{{invalid}}");
7843 assert_fragments!(fragments, [raw("{{{invalid}}"),]);
7844 }
7845
7846 #[test]
7847 fn test_handlebars_four_open_braces() {
7848 let (fragments, _) = parse_and_get_fragments("{{{{invalid}}");
7850 assert_fragments!(fragments, [raw("{{{{invalid}}"),]);
7851 }
7852
7853 #[test]
7854 fn test_handlebars_five_open_with_valid_double() {
7855 let (fragments, _) = parse_and_get_fragments("{{{{{invalid}}");
7857 assert_fragments!(fragments, [raw("{{{"), signal("invalid"),]);
7858 }
7859
7860 #[test]
7861 fn test_entities_preserved() {
7862 let (fragments, _) = parse_and_get_fragments("<p>Hello}World</p>");
7864 assert_fragments!(fragments, [raw("<p>Hello}World</p>"),]);
7865 }
7866
7867 #[test]
7870 fn test_doctype_preserved() {
7871 let (fragments, _) = parse_and_get_fragments("<!DOCTYPE html><html><head></head></html>");
7873 assert!(!fragments.is_empty());
7874 assert!(
7875 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("<!DOCTYPE html>"))
7876 );
7877 }
7878
7879 #[test]
7882 fn test_component_attr_skip() {
7883 let mut parser = HtmlParser::new();
7885 parser
7886 .component_registry
7887 .register_component(ComponentRegistration::new(
7888 "custom-element",
7889 r#"<template shadowrootmode="open"><slot></slot></template>"#,
7890 None,
7891 true,
7892 ))
7893 .expect("register");
7894 let html = r#"<custom-element :config="{{config}}" class="{{value0}}" style="{{value1}}" role="{{value2}}" data-test="{{value3}}" aria-test="{{value4}}"></custom-element>"#;
7895 let result = parser.parse("index.html", html);
7896 assert!(result.is_ok(), "Parse error: {:?}", result.err());
7897 let records = parser.into_fragment_records();
7898
7899 assert_fragments!(
7902 records["index.html"].fragments,
7903 [
7904 raw("<custom-element"),
7905 attr_complex_start(":config", "config"),
7907 attr_skip("class", "value0"),
7909 attr_skip("style", "value1"),
7910 attr_skip("role", "value2"),
7911 attr_skip("data-test", "value3"),
7912 attr("aria-test", "value4"),
7913 structural_matcher("streaming_root:custom-element"),
7914 raw(">"),
7915 component("custom-element"),
7916 raw("</custom-element>"),
7917 ]
7918 );
7919 }
7920
7921 #[test]
7922 fn test_component_attr_skip_static_and_embedded() {
7923 let mut parser = HtmlParser::new();
7926 parser
7927 .component_registry
7928 .register_component(ComponentRegistration::new(
7929 "item-group",
7930 r#"<template shadowrootmode="open"><slot></slot></template>"#,
7931 None,
7932 true,
7933 ))
7934 .expect("register");
7935
7936 let html = r#"<item-group role="list" aria-labelledby="group-date-{{group.id}}" data-testid="grp-{{group.id}}" class="fixed-class"></item-group>"#;
7937 let result = parser.parse("index.html", html);
7938 assert!(result.is_ok(), "Parse error: {:?}", result.err());
7939 let records = parser.into_fragment_records();
7940
7941 assert_fragments!(
7942 records["index.html"].fragments,
7943 [
7944 raw("<item-group"),
7945 attr_skip_raw("role", "list"),
7946 FragmentMatcher::Attribute(AttrMatcher {
7947 name: "aria-labelledby".into(),
7948 template: Some("attr-1".into()),
7949 attr_start: true,
7950 ..Default::default()
7951 }),
7952 attr_skip_template("data-testid", "attr-2"),
7953 attr_skip_raw("class", "fixed-class"),
7954 structural_matcher("streaming_root:item-group"),
7955 raw(">"),
7956 component("item-group"),
7957 raw("</item-group>"),
7958 ]
7959 );
7960
7961 assert_stream!(records, "attr-1", [raw("group-date-"), signal("group.id"),]);
7964 assert_stream!(records, "attr-2", [raw("grp-"), signal("group.id"),]);
7965 }
7966
7967 #[test]
7968 fn test_component_multiple_nested() {
7969 let mut parser = HtmlParser::with_options(DomStrategy::Light);
7971 parser
7972 .component_registry
7973 .register_component(ComponentRegistration::new(
7974 "custom-element",
7975 "<custom-child></custom-child><span></span>",
7976 None,
7977 true,
7978 ))
7979 .expect("register");
7980 parser
7981 .component_registry
7982 .register_component(ComponentRegistration::new(
7983 "custom-button",
7984 "<span></span>",
7985 None,
7986 true,
7987 ))
7988 .expect("register");
7989 parser
7990 .component_registry
7991 .register_component(ComponentRegistration::new(
7992 "custom-child",
7993 "<h1>Hello World!</h1>",
7994 None,
7995 true,
7996 ))
7997 .expect("register");
7998
7999 let html = r#"<for each="item in items"><custom-element><custom-button>Ok</custom-button></custom-element></for>"#;
8000 let result = parser.parse("index.html", html);
8001 assert!(result.is_ok(), "Parse error: {:?}", result.err());
8002 let records = parser.into_fragment_records();
8003
8004 assert_fragments!(
8006 records["index.html"].fragments,
8007 [for_loop("item", "items", "for-1"),]
8008 );
8009
8010 assert_stream!(
8012 records,
8013 "for-1",
8014 [
8015 raw("<custom-element"),
8016 structural_matcher("streaming_root:custom-element"),
8017 raw(">"),
8018 component("custom-element"),
8019 raw("<custom-button"),
8020 structural_matcher("streaming_root:custom-button"),
8021 raw(">"),
8022 component("custom-button"),
8023 raw("Ok</custom-button></custom-element>"),
8024 ]
8025 );
8026
8027 let ce = &records["custom-element"].fragments;
8029 assert_eq!(ce.len(), 5);
8030 assert!(
8031 matches!(ce[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.starts_with("<custom-child"))
8032 );
8033 assert!(
8034 matches!(ce[3].fragment.as_ref(), Some(Fragment::Component(c)) if c.fragment_id == "custom-child")
8035 );
8036 assert!(
8037 matches!(ce[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if raw.value.contains("</custom-child><span></span>"))
8038 );
8039
8040 assert_stream!(records, "custom-button", [raw("<span></span>"),]);
8041
8042 assert_stream!(records, "custom-child", [raw("<h1>Hello World!</h1>"),]);
8043 }
8044
8045 #[test]
8048 fn test_invalid_markup_returns_error() {
8049 let mut parser = HtmlParser::new();
8051 let result = parser.parse("index.html", "<div><span>Unclosed div");
8052 assert!(matches!(result, Err(ParserError::Template(diag)) if
8053 diag.to_string().contains("unclosed <div> tag")
8054 ));
8055 }
8056
8057 #[test]
8058 fn test_unterminated_opening_tag_returns_error() {
8059 let mut parser = HtmlParser::new();
8060 let result = parser.parse("index.html", "before <div");
8061 assert!(matches!(result, Err(ParserError::Template(diag)) if
8062 diag.to_string().contains("malformed HTML tag")
8063 ));
8064 }
8065
8066 #[test]
8067 fn test_unterminated_html_comment_returns_error() {
8068 let mut parser = HtmlParser::new();
8069 let result = parser.parse("index.html", "<div><!-- missing close</div>");
8070 assert!(matches!(result, Err(ParserError::Template(diag)) if
8071 diag.to_string().contains("unclosed <div> tag")
8072 || diag.to_string().contains("unterminated HTML comment")
8073 ));
8074 }
8075
8076 #[test]
8077 fn test_unexpected_closing_tag_returns_error() {
8078 let mut parser = HtmlParser::new();
8079 let result = parser.parse("index.html", "</span>");
8080 assert!(matches!(result, Err(ParserError::Template(diag)) if
8081 diag.to_string().contains("unexpected closing tag </span>")
8082 ));
8083 }
8084
8085 #[test]
8086 fn test_rejects_templates_over_size_limit() {
8087 let mut parser = HtmlParser::new();
8088 let html = "x".repeat(MAX_TEMPLATE_BYTES + 1);
8089 let result = parser.parse("index.html", &html);
8090
8091 assert!(matches!(result, Err(ParserError::Html(message)) if
8092 message.contains("exceeds") && message.contains("parser limit")
8093 ));
8094 }
8095
8096 #[test]
8097 fn test_rejects_templates_over_depth_limit() {
8098 let mut parser = HtmlParser::new();
8099 let mut html = String::with_capacity((MAX_TEMPLATE_DEPTH + 2) * 11);
8100 for _ in 0..=MAX_TEMPLATE_DEPTH {
8101 html.push_str("<div>");
8102 }
8103 for _ in 0..=MAX_TEMPLATE_DEPTH {
8104 html.push_str("</div>");
8105 }
8106
8107 let result = parser.parse("index.html", &html);
8108
8109 assert!(matches!(result, Err(ParserError::Template(diag)) if
8110 diag.to_string().contains("nesting exceeds")
8111 ));
8112 }
8113
8114 #[test]
8115 fn test_rejects_routes_over_depth_limit() {
8116 let mut parser = HtmlParser::new();
8117 parser
8118 .component_registry
8119 .register_component(ComponentRegistration::new(
8120 "route-page",
8121 r#"<template shadowrootmode="open"><slot></slot></template>"#,
8122 None,
8123 true,
8124 ))
8125 .expect("register");
8126
8127 let depth = MAX_TEMPLATE_DEPTH + 2;
8128 let mut html = String::with_capacity(depth * 48);
8129 for _ in 0..depth {
8130 html.push_str(r#"<route path="x" component="route-page">"#);
8131 }
8132 for _ in 0..depth {
8133 html.push_str("</route>");
8134 }
8135
8136 let result = parser.parse("index.html", &html);
8137
8138 assert!(matches!(result, Err(ParserError::Template(diag)) if
8139 diag.error_code() == Some(codes::EXCESSIVE_NESTING)
8140 && diag.to_string().contains("route nesting exceeds")
8141 ));
8142 }
8143
8144 #[test]
8145 fn test_rejects_recursive_component_templates() {
8146 let mut parser = HtmlParser::new();
8147 parser
8148 .component_registry
8149 .register_component(ComponentRegistration::new(
8150 "self-card",
8151 "<self-card></self-card>",
8152 None,
8153 true,
8154 ))
8155 .expect("register");
8156
8157 let result = parser.parse("index.html", "<self-card></self-card>");
8158
8159 assert!(matches!(result, Err(ParserError::Template(diag)) if
8160 diag.error_code() == Some(codes::RECURSIVE_TEMPLATE)
8161 && diag.to_string().contains("recursive template reference")
8162 ));
8163 }
8164
8165 #[test]
8168 fn test_complex_raw_text_full_page() {
8169 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>"#;
8171 let (fragments, _) = parse_and_get_fragments(html);
8172
8173 assert!(fragments.len() >= 9);
8176 assert!(
8177 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8178 raw.value.contains("<!DOCTYPE HTML>") && raw.value.ends_with("<head>"))
8179 );
8180 assert!(
8181 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
8182 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start"))
8183 );
8184 assert!(
8185 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8186 raw.value.contains("<title>Test</title>"))
8187 );
8188 assert!(
8189 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
8190 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end"))
8191 );
8192 assert!(
8193 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8194 raw.value.contains("</head>") && raw.value.ends_with("<body>"))
8195 );
8196 assert!(
8197 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
8198 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start"))
8199 );
8200 assert!(
8201 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8202 raw.value.contains("<app-shell>"))
8203 );
8204 assert!(
8205 matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
8206 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end"))
8207 );
8208 assert!(
8209 matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
8210 raw.value.contains("</body>") && raw.value.contains("</html>"))
8211 );
8212 }
8213
8214 #[test]
8215 fn raw_bindings_in_html_text_contexts_do_not_own_html_ranges() {
8216 for element in ["title", "textarea", "script", "xmp"] {
8217 let html = format!("<{element}>{{{{{{value}}}}}}</{element}>");
8218 let (fragments, _) = parse_and_get_fragments(&html);
8219 let signal = fragments
8220 .iter()
8221 .find_map(|fragment| match fragment.fragment.as_ref() {
8222 Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8223 _ => None,
8224 });
8225 let signal = signal
8226 .unwrap_or_else(|| panic!("expected raw signal inside <{element}>: {fragments:?}"));
8227 assert!(
8228 signal.raw,
8229 "<{element}> should preserve raw brace semantics"
8230 );
8231 assert!(
8232 signal.raw_text_context,
8233 "<{element}> should suppress HTML ownership markers"
8234 );
8235 }
8236 }
8237
8238 #[test]
8239 fn raw_bindings_in_template_content_own_html_ranges() {
8240 let (fragments, _) = parse_and_get_fragments("<template>{{{value}}}</template>");
8241 let signal = fragments
8242 .iter()
8243 .find_map(|fragment| match fragment.fragment.as_ref() {
8244 Some(Fragment::Signal(signal)) if signal.value == "value" => Some(signal),
8245 _ => None,
8246 });
8247 let signal =
8248 signal.unwrap_or_else(|| panic!("expected raw signal inside template: {fragments:?}"));
8249 assert!(signal.raw);
8250 assert!(!signal.raw_text_context);
8251 }
8252
8253 #[test]
8254 fn test_css_strategy_external_defers_link_to_server_closure() {
8255 let mut parser = HtmlParser::new();
8256 parser
8257 .component_registry_mut()
8258 .register_component(ComponentRegistration::new(
8259 "my-card",
8260 r#"<template shadowrootmode="open"><p>content</p></template>"#,
8261 Some("p { color: red; }"),
8262 true,
8263 ))
8264 .ok();
8265 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8266 let records = parser.into_fragment_records();
8267 let my_card = &records["my-card"].fragments;
8268 let raw_text: String = my_card
8269 .iter()
8270 .filter_map(|f| match &f.fragment {
8271 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8272 _ => None,
8273 })
8274 .collect();
8275 assert!(
8276 !raw_text.contains(r#"<link rel="stylesheet""#),
8277 "component templates must not own tree-local links: {}",
8278 raw_text
8279 );
8280 assert!(my_card.iter().any(|fragment| matches!(
8281 fragment.fragment.as_ref(),
8282 Some(Fragment::Signal(signal))
8283 if signal.value == "}}}webui:shadow_styles:my-card"
8284 )));
8285 }
8286
8287 #[test]
8288 fn test_uppercase_style_element_is_processed_as_css() {
8289 let (fragments, _) = parse_and_get_fragments("<STYLE>/*{{tokens}}*/.a{color:red}</STYLE>");
8294 assert!(
8295 fragments.iter().any(|f| matches!(
8296 f.fragment.as_ref(),
8297 Some(Fragment::Signal(s))
8298 if s.value == "tokens" && !s.raw && s.raw_text_context
8299 )),
8300 "uppercase <STYLE> should be CSS-processed, got: {:?}",
8301 fragments
8302 );
8303 let raw: String = fragments
8304 .iter()
8305 .filter_map(|f| match f.fragment.as_ref() {
8306 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8307 _ => None,
8308 })
8309 .collect();
8310 assert!(
8311 raw.contains("</STYLE>"),
8312 "closing tag should preserve source case, got: {raw}"
8313 );
8314 }
8315
8316 #[test]
8317 fn test_css_strategy_inline_defers_style_to_server_closure() {
8318 let mut parser = HtmlParser::with_options(CssStrategy::Style);
8319 parser
8320 .component_registry_mut()
8321 .register_component(ComponentRegistration::new(
8322 "my-card",
8323 "<p>content</p>",
8324 Some("p { color: red; }"),
8325 true,
8326 ))
8327 .expect("register component");
8328 parser
8329 .parse("index.html", "<my-card>Hello</my-card>")
8330 .expect("parse component");
8331 let records = parser.into_fragment_records();
8332 let my_card = &records["my-card"].fragments;
8333 let raw_text: String = my_card
8334 .iter()
8335 .filter_map(|f| match &f.fragment {
8336 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8337 _ => None,
8338 })
8339 .collect();
8340 assert!(
8341 !raw_text.contains("<style>"),
8342 "component templates must not own tree-local styles: {}",
8343 raw_text
8344 );
8345 assert!(
8346 !raw_text.contains("<link"),
8347 "Should not have <link> tag in inline mode: {}",
8348 raw_text
8349 );
8350 }
8351
8352 #[test]
8353 fn test_css_strategy_module_emits_adopted_stylesheets() {
8354 let mut parser = HtmlParser::with_options((CssStrategy::Module, DomStrategy::Light));
8355 parser
8356 .component_registry_mut()
8357 .register_component(ComponentRegistration::new(
8358 "my-card",
8359 "<p>content</p>",
8360 Some("p { color: red; }"),
8361 true,
8362 ))
8363 .expect("register component");
8364 parser
8365 .parse("index.html", "<my-card>Hello</my-card>")
8366 .expect("parse component");
8367 let records = parser.into_fragment_records();
8368
8369 let my_card = &records["my-card"].fragments;
8372 let template_text: String = my_card
8373 .iter()
8374 .filter_map(|f| match &f.fragment {
8375 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8376 _ => None,
8377 })
8378 .collect();
8379 assert!(
8380 !template_text.contains("shadowrootadoptedstylesheets"),
8381 "shadowrootadoptedstylesheets should NOT be in HTML output (now in metadata only): {template_text}"
8382 );
8383 assert!(
8384 !template_text.contains("<link"),
8385 "Should not have <link> in module mode: {template_text}"
8386 );
8387 assert!(
8391 !template_text.contains(r#"<style type="module""#),
8392 "CSS module should NOT be in raw fragments (legacy shape): {template_text}"
8393 );
8394 assert!(
8395 !template_text.contains(r#"<script type="importmap""#),
8396 "CSS module importmap should NOT be in raw fragments: {template_text}"
8397 );
8398 }
8399
8400 #[test]
8401 fn light_component_css_remains_authored_and_unscoped() {
8402 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8403 parser
8404 .component_registry_mut()
8405 .register_component(ComponentRegistration::new(
8406 "my-card",
8407 "<p class=\"label\">content</p>",
8408 Some(".label{color:red}"),
8409 false,
8410 ))
8411 .expect("register component");
8412 parser
8413 .parse("index.html", "<my-card></my-card>")
8414 .expect("parse component");
8415
8416 let css = parser
8417 .component_registry()
8418 .get("my-card")
8419 .and_then(|component| component.css_content.as_deref())
8420 .expect("authored CSS");
8421 assert_eq!(css, ".label{color:red}");
8422 }
8423
8424 #[test]
8425 fn light_component_inline_styles_remain_unscoped_and_unmarked() {
8426 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8427 let built = parser
8428 .build_component_templates(ComponentTemplateInput {
8429 tag_name: "my-card",
8430 html: "<style>.label{color:red}</style><p class=\"label\">content</p>",
8431 artifact_html: None,
8432 authored_html: None,
8433 css_content: None,
8434 artifact_needed: false,
8435 })
8436 .expect("build Light component");
8437
8438 assert_eq!(
8439 built.ssr,
8440 "<style>.label{color:red}</style><p class=\"label\">content</p>"
8441 );
8442 assert!(!built.ssr.contains("data-wl"));
8443 }
8444
8445 #[test]
8446 fn light_component_rejects_shadow_only_selectors() {
8447 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8448 for selector in [":host", ":host-context(body.dark)", "::slotted(*)"] {
8449 let html = format!("<style>{selector}{{color:red}}</style><p>content</p>");
8450 let result = parser.build_component_templates(ComponentTemplateInput {
8451 tag_name: "my-card",
8452 html: &html,
8453 artifact_html: None,
8454 authored_html: None,
8455 css_content: None,
8456 artifact_needed: false,
8457 });
8458 let error = match result {
8459 Ok(_) => panic!("Shadow-only selector must fail in Light DOM"),
8460 Err(error) => error,
8461 };
8462 assert!(matches!(
8463 error,
8464 ParserError::Template(ref diagnostic)
8465 if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8466 ));
8467 }
8468 }
8469
8470 #[test]
8471 fn light_component_external_css_rejects_host_selector() {
8472 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8473 parser
8474 .component_registry_mut()
8475 .register_component(ComponentRegistration::new(
8476 "my-card",
8477 "<p>content</p>",
8478 Some(":host{display:block}"),
8479 false,
8480 ))
8481 .expect("register component");
8482 let error = parser
8483 .parse("index.html", "<my-card></my-card>")
8484 .expect_err("external :host must fail in Light DOM");
8485 assert!(matches!(
8486 error,
8487 ParserError::Template(ref diagnostic)
8488 if diagnostic.error_code() == Some(codes::UNSUPPORTED_LIGHT_CSS)
8489 ));
8490 }
8491
8492 #[test]
8493 fn shadow_component_inline_styles_remain_tree_local_and_unchanged() {
8494 let mut parser = HtmlParser::with_options(DomStrategy::Shadow);
8495 let built = parser
8496 .build_component_templates(ComponentTemplateInput {
8497 tag_name: "my-card",
8498 html: "<style>:host{display:block}.label{color:red}</style><p class=\"label\">content</p>",
8499 artifact_html: None,
8500 authored_html: None,
8501 css_content: None,
8502 artifact_needed: false,
8503 })
8504 .expect("build Shadow component");
8505
8506 assert!(built.ssr.contains(
8507 "<template shadowrootmode=\"open\"><style>:host{display:block}.label{color:red}</style>"
8508 ));
8509 assert!(!built.ssr.contains("data-wl"));
8510 }
8511
8512 #[test]
8513 fn nested_light_hosts_are_unmarked() {
8514 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8515 parser
8516 .component_registry_mut()
8517 .register_component(ComponentRegistration::new(
8518 "my-child",
8519 "<p class=\"inner\">child</p>",
8520 Some(".inner{color:green}"),
8521 false,
8522 ))
8523 .expect("register child");
8524 parser
8525 .component_registry_mut()
8526 .register_component(ComponentRegistration::new(
8527 "my-card",
8528 "<my-child></my-child>",
8529 Some("my-child{display:block}"),
8530 false,
8531 ))
8532 .expect("register parent");
8533 parser
8534 .parse("index.html", "<my-card></my-card>")
8535 .expect("parse component");
8536
8537 let records = parser.into_fragment_records();
8538 let raw: String = records["my-card"]
8539 .fragments
8540 .iter()
8541 .filter_map(|fragment| match &fragment.fragment {
8542 Some(web_ui_fragment::Fragment::Raw(raw)) => Some(raw.value.as_str()),
8543 _ => None,
8544 })
8545 .collect();
8546 assert!(raw.contains("<my-child>"), "nested host was changed: {raw}");
8547 assert!(!raw.contains("data-wl"), "Light markers remain: {raw}");
8548 }
8549
8550 #[test]
8551 fn raw_html_binding_does_not_change_global_css() {
8552 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8553 parser
8554 .component_registry_mut()
8555 .register_component(ComponentRegistration::new(
8556 "my-card",
8557 "<div class=\"body\">{{{descriptionHtml}}}</div>",
8558 Some(".body p{margin:0}"),
8559 false,
8560 ))
8561 .expect("register component");
8562 parser
8563 .parse("index.html", "<my-card></my-card>")
8564 .expect("parse component");
8565
8566 let css = parser
8567 .component_registry()
8568 .get("my-card")
8569 .and_then(|component| component.css_content.as_deref())
8570 .expect("authored CSS");
8571 assert_eq!(css, ".body p{margin:0}");
8572 }
8573
8574 #[test]
8575 fn test_css_strategy_module_no_css_no_adopted_attr() {
8576 let mut parser = HtmlParser::with_options(CssStrategy::Module);
8577 parser
8578 .component_registry_mut()
8579 .register_component(ComponentRegistration::new(
8580 "my-card",
8581 r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
8582 None,
8583 true,
8584 ))
8585 .ok();
8586 parser.parse("index.html", "<my-card>Hello</my-card>").ok();
8587 let records = parser.into_fragment_records();
8588 let my_card = &records["my-card"].fragments;
8589 let template_text: String = my_card
8590 .iter()
8591 .filter_map(|f| match &f.fragment {
8592 Some(Fragment::Raw(r)) => Some(r.value.as_str()),
8593 _ => None,
8594 })
8595 .collect();
8596 assert!(
8598 !template_text.contains("shadowrootadoptedstylesheets"),
8599 "Should not have adopted attr without CSS: {template_text}"
8600 );
8601 }
8602
8603 #[test]
8616 fn light_preserves_dev_template_with_static_attrs() {
8617 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8618 let processed = parser
8619 .process_component_template(
8620 r#"<template foo="bar"><div>hi</div></template>"#,
8621 None,
8622 None,
8623 )
8624 .expect("process failed");
8625 assert!(
8626 processed.contains(r#"<template foo="bar">"#),
8627 "expected dev <template foo=\"bar\"> preserved verbatim, got: {processed}"
8628 );
8629 assert!(
8630 processed.contains("</template>"),
8631 "expected closing </template> preserved, got: {processed}"
8632 );
8633 }
8634
8635 #[test]
8636 fn light_preserves_dev_template_with_signal_fragment_attrs() {
8637 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8638 let processed = parser
8639 .process_component_template(
8640 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8641 None,
8642 None,
8643 )
8644 .expect("process failed");
8645 assert!(
8646 processed.contains(r#"<template foo="{{foo}}">"#),
8647 "expected dev <template foo=\"{{{{foo}}}}\"> with signal preserved, got: {processed}"
8648 );
8649 }
8650
8651 #[test]
8652 fn light_preserves_dev_template_with_multiple_attrs() {
8653 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8654 let processed = parser.process_component_template(
8655 r#"<template autofocus tabindex="0" role="region" data-x="y"><div>hi</div></template>"#,
8656 None,
8657 None,
8658 )
8659 .expect("process failed");
8660 assert!(
8661 processed.contains("autofocus")
8662 && processed.contains(r#"tabindex="0""#)
8663 && processed.contains(r#"role="region""#)
8664 && processed.contains(r#"data-x="y""#),
8665 "expected all dev template attrs preserved, got: {processed}"
8666 );
8667 }
8668
8669 #[test]
8670 fn light_no_template_no_wrapper_added() {
8671 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8672 let processed = parser
8673 .process_component_template("<div>hi</div>", None, None)
8674 .expect("process failed");
8675 assert!(
8676 !processed.contains("<template"),
8677 "framework must not add a <template> wrapper when dev omits one, got: {processed}"
8678 );
8679 assert!(
8680 processed.contains("<div>hi</div>"),
8681 "expected inner content emitted as-is, got: {processed}"
8682 );
8683 }
8684
8685 #[test]
8686 fn ordinary_template_remains_light_dom_content() {
8687 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8688 let processed = parser
8689 .process_component_template(
8690 r#"<template foo="bar"><div>hi</div></template>"#,
8691 None,
8692 None,
8693 )
8694 .expect("process failed");
8695 assert!(
8696 processed.contains(r#"<template foo="bar">"#),
8697 "ordinary template content must be preserved, got: {processed}"
8698 );
8699 assert!(
8700 !processed.contains("shadowrootmode"),
8701 "ordinary template must not gain a generated Shadow root, got: {processed}"
8702 );
8703 }
8704
8705 #[test]
8706 fn bare_template_is_unwrapped_as_explicit_light_dom() {
8707 let mut parser = HtmlParser::new();
8708 let built = parser
8709 .build_component_templates(ComponentTemplateInput {
8710 tag_name: "my-card",
8711 html: "<template><div class=\"label\">hi</div></template>",
8712 artifact_html: None,
8713 authored_html: None,
8714 css_content: Some(".label{color:red}"),
8715 artifact_needed: false,
8716 })
8717 .expect("bare template should select Light DOM");
8718 assert!(!built.uses_shadow_dom);
8719 assert_eq!(built.ssr, r#"<div class="label">hi</div>"#);
8720 }
8721
8722 #[test]
8723 fn attributed_template_keeps_the_shadow_fallback() {
8724 let mut parser = HtmlParser::new();
8725 let built = parser
8726 .build_component_templates(ComponentTemplateInput {
8727 tag_name: "my-card",
8728 html: r#"<template data-purpose="content"><div>hi</div></template>"#,
8729 artifact_html: None,
8730 authored_html: None,
8731 css_content: None,
8732 artifact_needed: false,
8733 })
8734 .expect("attributed template should remain ordinary content");
8735 assert!(built.uses_shadow_dom);
8736 assert_eq!(
8737 built.ssr,
8738 r#"<template shadowrootmode="open"><template data-purpose="content"><div>hi</div></template></template>"#
8739 );
8740 }
8741
8742 #[test]
8743 fn closed_shadow_wrapper_is_rejected_during_template_processing() {
8744 let mut parser = HtmlParser::new();
8745 let error = parser
8746 .process_component_template(
8747 r#"<template shadowrootmode="closed"><div>hi</div></template>"#,
8748 None,
8749 None,
8750 )
8751 .expect_err("closed roots must fail");
8752 assert!(matches!(
8753 error,
8754 ParserError::Template(ref diagnostic)
8755 if diagnostic.error_code() == Some(codes::INVALID_SHADOW_ROOT_MODE)
8756 ));
8757 }
8758
8759 #[test]
8760 fn ordinary_template_preserves_signal_fragment_attrs() {
8761 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8762 let processed = parser
8763 .process_component_template(
8764 r#"<template foo="{{foo}}"><div>hi</div></template>"#,
8765 None,
8766 None,
8767 )
8768 .expect("process failed");
8769 assert!(
8770 processed.starts_with(r#"<template foo="{{foo}}">"#),
8771 "ordinary template must remain Light content, got: {processed}"
8772 );
8773 }
8774
8775 #[test]
8776 fn unwrapped_component_never_gains_shadow_wrapper() {
8777 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8778 let processed = parser
8779 .process_component_template("<div>hi</div>", None, None)
8780 .expect("process failed");
8781 assert!(
8782 !processed.contains("shadowrootmode"),
8783 "framework must not generate a Shadow root, got: {processed}"
8784 );
8785 assert!(
8786 processed.contains("<div>hi</div>"),
8787 "Light content must survive processing, got: {processed}"
8788 );
8789 }
8790
8791 #[test]
8792 fn default_unwrapped_component_gains_open_shadow_wrapper() {
8793 let mut parser = HtmlParser::new();
8794 let processed = parser
8795 .process_component_template("<div>hi</div>", None, None)
8796 .expect("process failed");
8797 assert_eq!(
8798 processed,
8799 r#"<template shadowrootmode="open"><div>hi</div></template>"#
8800 );
8801 }
8802
8803 #[test]
8804 fn leading_comments_do_not_change_root_template_detection() {
8805 const COMMENT: &str = "<!-- Copyright (C) Corporation. All rights reserved. -->";
8806 let fixtures = [
8807 (
8808 format!(
8809 "{COMMENT}\n<template shadowrootmode=\"open\">\n <h1>Hello</h1>\n</template>"
8810 ),
8811 "<template shadowrootmode=\"open\">\n <h1>Hello</h1>\n</template>",
8812 ),
8813 (format!("{COMMENT}\n<h1>Hello</h1>"), "<h1>Hello</h1>"),
8814 (format!("{COMMENT}\nHello"), "Hello"),
8815 ];
8816
8817 for (input, expected) in fixtures {
8818 let mut parser = HtmlParser::with_options(DomStrategy::Light);
8819 let processed = parser
8820 .process_component_template(&input, None, None)
8821 .expect("leading-comment fixture should process");
8822 assert_eq!(processed, expected);
8823 }
8824 }
8825
8826 #[test]
8827 fn component_policy_wrapper_is_build_only_and_unwrapped_for_light() {
8828 let html = r#"<template w-hydrate="lazy"><div>hi</div></template>"#;
8832 let mut shadow = HtmlParser::with_options(DomStrategy::Shadow);
8833 let shadow_built = shadow
8834 .build_component_templates(ComponentTemplateInput {
8835 tag_name: "my-comp",
8836 html,
8837 artifact_html: None,
8838 authored_html: None,
8839 css_content: None,
8840 artifact_needed: true,
8841 })
8842 .expect("shadow policy wrapper should compile");
8843 assert_eq!(
8844 shadow_built.ssr,
8845 r#"<template shadowrootmode="open"><div>hi</div></template>"#
8846 );
8847 assert!(!shadow_built.artifact().contains("w-hydrate"));
8848 let mut light = HtmlParser::with_options(DomStrategy::Light);
8849 let light_built = light
8850 .build_component_templates(ComponentTemplateInput {
8851 tag_name: "my-comp",
8852 html,
8853 artifact_html: None,
8854 authored_html: None,
8855 css_content: None,
8856 artifact_needed: true,
8857 })
8858 .expect("light policy wrapper should compile");
8859 assert_eq!(light_built.ssr, "<div>hi</div>");
8860 assert_eq!(light_built.artifact(), "<div>hi</div>");
8861
8862 let mut implicit_shadow = HtmlParser::new();
8863 let shadow_built = implicit_shadow
8864 .build_component_templates(ComponentTemplateInput {
8865 tag_name: "my-comp",
8866 html,
8867 artifact_html: None,
8868 authored_html: None,
8869 css_content: None,
8870 artifact_needed: true,
8871 })
8872 .expect("default policy wrapper should compile as Shadow");
8873 assert_eq!(
8874 shadow_built.ssr,
8875 r#"<template shadowrootmode="open"><div>hi</div></template>"#
8876 );
8877 assert!(!shadow_built.artifact().contains("w-hydrate"));
8878
8879 let shadow_html =
8882 r#"<template shadowrootmode="open" w-hydrate="lazy"><div>hi</div></template>"#;
8883 let mut shadow = HtmlParser::new();
8884 let authored_shadow_built = shadow
8885 .build_component_templates(ComponentTemplateInput {
8886 tag_name: "my-comp",
8887 html: shadow_html,
8888 artifact_html: None,
8889 authored_html: None,
8890 css_content: None,
8891 artifact_needed: true,
8892 })
8893 .expect("shadow policy wrapper should compile");
8894 assert_eq!(
8895 authored_shadow_built.ssr,
8896 r#"<template shadowrootmode="open"><div>hi</div></template>"#
8897 );
8898 assert!(!authored_shadow_built.artifact().contains("w-hydrate"));
8899 }
8900
8901 #[test]
8902 fn interaction_policy_marks_component_hosts_in_ssr_fragments() {
8903 let mut parser = HtmlParser::new();
8904 parser
8905 .component_registry
8906 .register_component(ComponentRegistration::new(
8907 "interaction-shell",
8908 r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
8909 None,
8910 true,
8911 ))
8912 .expect("component registration");
8913 parser
8914 .parse(
8915 "index.html",
8916 "<body><interaction-shell></interaction-shell></body>",
8917 )
8918 .expect("entry parse");
8919 let records = parser.into_fragment_records();
8920 let entry_html: String = records["index.html"]
8921 .fragments
8922 .iter()
8923 .filter_map(|fragment| match fragment.fragment.as_ref() {
8924 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8925 _ => None,
8926 })
8927 .collect();
8928 assert!(entry_html.contains("<interaction-shell data-webui-interaction"));
8929 }
8930
8931 #[test]
8932 fn combined_interaction_policy_marks_hosts_and_emits_render_css() {
8933 let mut parser = HtmlParser::new();
8934 parser
8935 .component_registry
8936 .register_component(ComponentRegistration::new(
8937 "interaction-panel",
8938 concat!(
8939 r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
8940 r#"w-hydrate="interaction"><button>Open</button></template>"#,
8941 ),
8942 None,
8943 true,
8944 ))
8945 .expect("component registration");
8946 parser
8947 .parse(
8948 "index.html",
8949 "<body><interaction-panel></interaction-panel></body>",
8950 )
8951 .expect("entry parse");
8952 assert!(parser
8953 .component_registry
8954 .render_policy_css(["interaction-panel"])
8955 .contains("content-visibility:auto"));
8956 let records = parser.into_fragment_records();
8957 let entry_html: String = records["index.html"]
8958 .fragments
8959 .iter()
8960 .filter_map(|fragment| match fragment.fragment.as_ref() {
8961 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
8962 _ => None,
8963 })
8964 .collect();
8965 assert!(entry_html.contains("<interaction-panel data-webui-interaction"));
8966 }
8967
8968 #[test]
8978 fn dev_template_module_strategy_appends_adopted_attr_when_missing() {
8979 let mut parser = HtmlParser::new();
8980 let processed = parser
8981 .process_component_template(
8982 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
8983 None,
8984 Some("my-comp"),
8985 )
8986 .expect("process failed");
8987 assert!(
8988 processed.contains(r#"shadowrootmode="open""#),
8989 "dev-authored shadowrootmode must be preserved, got: {processed}"
8990 );
8991 assert!(
8992 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
8993 "module CSS should append adopted stylesheets, got: {processed}"
8994 );
8995 }
8996
8997 #[test]
8998 fn dev_template_module_strategy_ok_when_dev_supplies_adopted_attr() {
8999 let mut parser = HtmlParser::new();
9000 let processed = parser
9001 .process_component_template(
9002 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><div>hi</div></template>"#,
9003 None,
9004 Some("my-comp"),
9005 )
9006 .unwrap();
9007 assert!(
9008 processed.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
9009 "dev-supplied adopted-stylesheets attr must be preserved verbatim, got: {processed}"
9010 );
9011 assert_eq!(
9012 processed.matches("shadowrootadoptedstylesheets").count(),
9013 1,
9014 "framework must not duplicate dev-supplied adopted-stylesheets attr, got: {processed}"
9015 );
9016 }
9017
9018 #[test]
9019 fn dev_template_module_strategy_appends_adopted_attr_and_preserves_root_attrs() {
9020 for dom_strategy in [DomStrategy::Shadow, DomStrategy::Light] {
9021 let mut parser = HtmlParser::with_options((CssStrategy::Module, dom_strategy));
9022 let built = match parser.build_component_templates(ComponentTemplateInput {
9023 tag_name: "my-comp",
9024 html: r#"<template shadowrootmode="open" @click="{onClick()}">Hello</template>"#,
9025 artifact_html: None,
9026 authored_html: None,
9027 css_content: Some(":host { color: red; }"),
9028 artifact_needed: true,
9029 }) {
9030 Ok(built) => built,
9031 Err(err) => panic!(
9032 "dev-authored <template> should be accepted under {dom_strategy:?} with module CSS, got: {err}"
9033 ),
9034 };
9035 let artifact = built.artifact();
9036
9037 assert!(artifact.contains(r#"shadowrootmode="open""#));
9038 assert!(artifact.contains(r#"@click="{onClick()}""#));
9039 assert!(artifact.contains(r#"shadowrootadoptedstylesheets="my-comp""#));
9040 assert_eq!(artifact.matches("shadowrootadoptedstylesheets").count(), 1);
9041 assert!(!built.ssr.contains("@click"));
9042 }
9043 }
9044
9045 #[test]
9046 fn dev_template_module_strategy_ok_with_multi_specifier_attr() {
9047 let mut parser = HtmlParser::new();
9052 let processed = parser
9053 .process_component_template(
9054 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp other-sheet"><div>hi</div></template>"#,
9055 None,
9056 Some("my-comp"),
9057 )
9058 .unwrap();
9059 assert!(
9060 processed.contains(r#"shadowrootadoptedstylesheets="my-comp other-sheet""#),
9061 "dev's multi-specifier list must be preserved verbatim, got: {processed}"
9062 );
9063 }
9064
9065 #[test]
9066 fn dev_template_no_adopted_specifier_does_not_validate() {
9067 let mut parser = HtmlParser::new();
9071 let processed = parser
9072 .process_component_template(
9073 r#"<template shadowrootmode="open"><div>hi</div></template>"#,
9074 None,
9075 None,
9076 )
9077 .unwrap();
9078 assert!(
9079 !processed.contains("shadowrootadoptedstylesheets"),
9080 "framework must NOT inject adopted-stylesheets attr without CssStrategy::Module signal, got: {processed}"
9081 );
9082 }
9083
9084 #[test]
9096 fn test_body_start_end_injection() {
9097 let html = r#"<html><head><title>Test</title></head><body><div>Content</div><p>More</p></body></html>"#;
9101 let (fragments, _) = parse_and_get_fragments(html);
9102
9103 assert_fragments!(
9104 fragments,
9105 [
9106 raw("<html><head>"),
9107 structural_matcher("head_start"),
9108 raw("<title>Test</title>"),
9109 structural_matcher("head_end"),
9110 raw("</head><body>"),
9111 structural_matcher("body_start"),
9112 raw("<div>Content</div><p>More</p>"),
9113 structural_matcher("body_end"),
9114 raw("</body></html>"),
9115 ]
9116 );
9117 }
9118
9119 #[test]
9120 fn test_body_preserves_static_attributes() {
9121 let html = r#"<html><head><title>T</title></head><body data-layout="doc" class="page"><p>x</p></body></html>"#;
9126 let (fragments, _) = parse_and_get_fragments(html);
9127
9128 assert_fragments!(
9129 fragments,
9130 [
9131 raw("<html><head>"),
9132 structural_matcher("head_start"),
9133 raw("<title>T</title>"),
9134 structural_matcher("head_end"),
9135 raw(r#"</head><body data-layout="doc" class="page">"#),
9136 structural_matcher("body_start"),
9137 raw("<p>x</p>"),
9138 structural_matcher("body_end"),
9139 raw("</body></html>"),
9140 ]
9141 );
9142 }
9143
9144 #[test]
9145 fn test_body_preserves_dynamic_attributes() {
9146 let html = r#"<html><head><title>T</title></head><body data-layout="{{layout}}"><p>x</p></body></html>"#;
9150 let (fragments, _) = parse_and_get_fragments(html);
9151
9152 assert_fragments!(
9153 fragments,
9154 [
9155 raw("<html><head>"),
9156 structural_matcher("head_start"),
9157 raw("<title>T</title>"),
9158 structural_matcher("head_end"),
9159 raw("</head><body"),
9160 attr("data-layout", "layout"),
9161 raw(">"),
9162 structural_matcher("body_start"),
9163 raw("<p>x</p>"),
9164 structural_matcher("body_end"),
9165 raw("</body></html>"),
9166 ]
9167 );
9168 }
9169
9170 #[test]
9171 fn test_head_start_signal_ordering_with_authored_script() {
9172 let html = r#"<html><head><script async src="./analytics.js"></script></head><body></body></html>"#;
9177 let (fragments, _) = parse_and_get_fragments(html);
9178
9179 assert_fragments!(
9180 fragments,
9181 [
9182 raw("<html><head>"),
9183 structural_matcher("head_start"),
9184 raw(r#"<script async src="./analytics.js"></script>"#),
9185 structural_matcher("head_end"),
9186 raw("</head><body>"),
9187 structural_matcher("body_start"),
9188 structural_matcher("body_end"),
9189 raw("</body></html>"),
9190 ]
9191 );
9192 }
9193
9194 #[test]
9195 fn test_head_static_attributes_preserve_legacy_opening_bytes() {
9196 let html = r#"<html><head data-theme="dark" class="app-head"><title>T</title></head><body></body></html>"#;
9200 let (fragments, _) = parse_and_get_fragments(html);
9201
9202 assert_fragments!(
9203 fragments,
9204 [
9205 raw("<html><head>"),
9206 structural_matcher("head_start"),
9207 raw("<title>T</title>"),
9208 structural_matcher("head_end"),
9209 raw("</head><body>"),
9210 structural_matcher("body_start"),
9211 structural_matcher("body_end"),
9212 raw("</body></html>"),
9213 ]
9214 );
9215 }
9216
9217 #[test]
9218 fn test_head_bound_attributes_preserve_legacy_opening_bytes() {
9219 let html =
9223 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body></body></html>"#;
9224 let (fragments, _) = parse_and_get_fragments(html);
9225
9226 assert_fragments!(
9227 fragments,
9228 [
9229 raw("<html><head>"),
9230 structural_matcher("head_start"),
9231 raw("<title>T</title>"),
9232 structural_matcher("head_end"),
9233 raw("</head><body>"),
9234 structural_matcher("body_start"),
9235 structural_matcher("body_end"),
9236 raw("</body></html>"),
9237 ]
9238 );
9239 }
9240
9241 #[test]
9242 fn native_head_and_body_are_ascii_case_insensitive() {
9243 let html = r#"<html><HEAD data-theme="ignored"><title>T</title></HEAD><BODY data-layout="{{layout}}"><p>x</p></BODY></html>"#;
9244 let (fragments, _) = parse_and_get_fragments(html);
9245
9246 assert_fragments!(
9247 fragments,
9248 [
9249 raw(r#"<html><HEAD data-theme="ignored">"#),
9250 structural_matcher("head_start"),
9251 raw("<title>T</title>"),
9252 structural_matcher("head_end"),
9253 raw("</HEAD><BODY"),
9254 attr("data-layout", "layout"),
9255 raw(">"),
9256 structural_matcher("body_start"),
9257 raw("<p>x</p>"),
9258 structural_matcher("body_end"),
9259 raw("</BODY></html>"),
9260 ]
9261 );
9262 }
9263
9264 #[test]
9268 fn entry_boundary_is_an_inline_start_end_tape() {
9269 let html = r#"<body><div><boundary name="counter-ready"><my-counter></my-counter></boundary></div></body>"#;
9270 let (fragments, records) = parse_and_get_fragments(html);
9271
9272 let boundary = match fragments[3].fragment.as_ref() {
9273 Some(Fragment::Boundary(boundary)) => boundary,
9274 other => panic!("expected typed boundary start, got {other:?}"),
9275 };
9276 assert_eq!(boundary.phase(), BoundaryPhase::Start);
9277 assert_eq!(boundary.declaration_id, 0);
9278 assert_eq!(boundary.owner_fragment_id, "index.html");
9279 assert_eq!(boundary.name, "counter-ready");
9280 assert_eq!(boundary.key, None);
9281 assert!(!boundary.may_repeat);
9282 assert_stream!(
9285 records,
9286 "index.html",
9287 [
9288 raw("<body>"),
9289 structural_matcher("body_start"),
9290 raw("<div>"),
9291 boundary_matcher("counter-ready", 0),
9292 raw("<my-counter></my-counter>"),
9293 boundary_end_matcher(0),
9294 raw("</div>"),
9295 structural_matcher("body_end"),
9296 raw("</body>"),
9297 ]
9298 );
9299 assert!(records["index.html"].contains_boundary);
9300 assert!(
9301 !records.contains_key("boundary-1"),
9302 "an inline tape must not create a body record"
9303 );
9304 }
9305
9306 #[test]
9307 fn boundary_names_are_scoped_per_owner_and_declaration_ids_are_protocol_wide() {
9308 let mut parser = HtmlParser::new();
9309 parser
9310 .parse(
9311 "first.html",
9312 r#"<body><boundary name="a"><p>1</p></boundary></body>"#,
9313 )
9314 .expect("first entry parses");
9315 parser
9316 .parse(
9317 "second.html",
9318 r#"<body><boundary name="a"><p>2</p></boundary></body>"#,
9319 )
9320 .expect("a second owner may reuse the name");
9321 let records = parser.into_fragment_records();
9322 let first = records["first.html"]
9323 .fragments
9324 .iter()
9325 .find_map(|fragment| match fragment.fragment.as_ref() {
9326 Some(Fragment::Boundary(boundary)) => Some(boundary),
9327 _ => None,
9328 })
9329 .expect("first boundary");
9330 let second = records["second.html"]
9331 .fragments
9332 .iter()
9333 .find_map(|fragment| match fragment.fragment.as_ref() {
9334 Some(Fragment::Boundary(boundary)) => Some(boundary),
9335 _ => None,
9336 })
9337 .expect("second boundary");
9338 assert_eq!((first.declaration_id, first.name.as_str()), (0, "a"));
9339 assert_eq!((second.declaration_id, second.name.as_str()), (1, "a"));
9340 }
9341
9342 #[test]
9343 fn boundary_missing_name_errors() {
9344 for html in [
9345 "<body><boundary><p>x</p></boundary></body>",
9346 r#"<body><boundary name=""><p>x</p></boundary></body>"#,
9347 r#"<body><boundary name=" "><p>x</p></boundary></body>"#,
9348 ] {
9349 let mut parser = HtmlParser::new();
9350 let err = parser
9351 .parse("index.html", html)
9352 .expect_err("a boundary without a non-empty name must error");
9353 let ParserError::Template(diag) = err else {
9354 panic!("expected ParserError::Template, got {err:?}");
9355 };
9356 assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_NAME));
9357 }
9358 }
9359
9360 #[test]
9361 fn boundary_dynamic_name_errors() {
9362 let mut parser = HtmlParser::new();
9363 let err = parser
9364 .parse(
9365 "index.html",
9366 r#"<body><boundary name="{{sectionName}}"><p>x</p></boundary></body>"#,
9367 )
9368 .expect_err("a dynamic boundary name must error");
9369 let ParserError::Template(diag) = err else {
9370 panic!("expected ParserError::Template, got {err:?}");
9371 };
9372 assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_NAME));
9373 }
9374
9375 #[test]
9376 fn boundary_names_are_free_form_and_live_on_typed_fragments() {
9377 let mut parser = HtmlParser::new();
9378 let html = concat!(
9379 "<body>",
9380 r#"<boundary name="above the fold"><p>1</p></boundary>"#,
9381 r#"<boundary name="feed/items #2"><p>2</p></boundary>"#,
9382 r#"<boundary name="ダッシュボード"><p>3</p></boundary>"#,
9383 "</body>",
9384 );
9385 parser
9386 .parse("index.html", html)
9387 .expect("free-form boundary names must be accepted");
9388 let records = parser.into_fragment_records();
9389 let names: Vec<&str> = records["index.html"]
9390 .fragments
9391 .iter()
9392 .filter_map(|fragment| match fragment.fragment.as_ref() {
9393 Some(Fragment::Boundary(boundary)) if boundary.phase() == BoundaryPhase::Start => {
9394 Some(boundary.name.as_str())
9395 }
9396 _ => None,
9397 })
9398 .collect();
9399 assert_eq!(names, ["above the fold", "feed/items #2", "ダッシュボード"]);
9400 }
9401
9402 #[test]
9403 fn module_entry_srcs_records_only_preloadable_critical_entries() {
9404 let mut parser = HtmlParser::new();
9405 parser
9406 .component_registry
9407 .register_component(ComponentRegistration::new(
9408 "weather-island",
9409 r#"<script type="module" src="/nested-weather.js"></script>"#,
9410 None,
9411 true,
9412 ))
9413 .expect("register");
9414 let html = concat!(
9415 "<head>",
9416 r#"<script type="module" async src="/index.js"></script>"#,
9418 r#"<SCRIPT type=" Module " src="/late.js"></SCRIPT>"#,
9422 r#"<script src="/legacy.js"></script>"#,
9424 r#"<script type="module">import "./x.js";</script>"#,
9426 r#"<script type="module" src="/{{bundle}}.js"></script>"#,
9428 "</head>",
9429 "<body>",
9430 r#"<boundary name="weather">"#,
9432 r#"<script type="module" async src="/weather-panel.js"></script>"#,
9433 "<weather-island></weather-island>",
9434 "</boundary>",
9435 r#"<script type="module" src="/index.js"></script>"#,
9438 "</body>",
9439 );
9440 parser.parse("index.html", html).expect("parse");
9441
9442 assert_eq!(
9443 parser.module_entry_srcs(),
9444 ["/index.js", "/late.js"],
9445 "only static module entries outside boundaries are preloadable"
9446 );
9447 }
9448
9449 #[test]
9450 fn module_entry_srcs_reset_between_top_level_parses() {
9451 let mut parser = HtmlParser::new();
9452 parser
9453 .parse(
9454 "a.html",
9455 r#"<head><script type="module" src="/a.js"></script></head>"#,
9456 )
9457 .expect("parse a");
9458 assert_eq!(parser.module_entry_srcs(), ["/a.js"]);
9459
9460 parser
9461 .parse(
9462 "b.html",
9463 r#"<head><script type="module" src="/b.js"></script></head>"#,
9464 )
9465 .expect("parse b");
9466 assert_eq!(
9467 parser.module_entry_srcs(),
9468 ["/b.js"],
9469 "each entry owns its own critical modules"
9470 );
9471 }
9472
9473 #[test]
9474 fn module_entry_srcs_preserve_low_priority_script_delivery() {
9475 let mut parser = HtmlParser::new();
9476 let html = concat!(
9477 "<head>",
9478 r#"<script type="module" async src="/streaming.js"></script>"#,
9479 r#"<script type="module" src="/low-head.js" fetchpriority="LOW"></script>"#,
9480 "</head><body>",
9481 r#"<script type="module" src="/application.js" fetchpriority="low"></script>"#,
9482 r#"<script type="module" src="/low-cased.js" FETCHPRIORITY="LoW"></script>"#,
9483 r#"<script type="module" src="/critical.js" fetchpriority="high"></script>"#,
9484 r#"<script type="module" src="/ordinary.js"></script>"#,
9485 "</body>",
9486 );
9487 parser.parse("index.html", html).expect("parse");
9488 assert_eq!(
9489 parser.module_entry_srcs(),
9490 ["/streaming.js", "/critical.js", "/ordinary.js"],
9491 "low-priority application chunks must not be pulled into head preloads"
9492 );
9493 let records = parser.into_fragment_records();
9494 let raw = records["index.html"]
9495 .fragments
9496 .iter()
9497 .filter_map(|fragment| match fragment.fragment.as_ref() {
9498 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
9499 _ => None,
9500 })
9501 .collect::<String>();
9502 assert!(
9503 raw.contains(r#"src="/application.js" fetchpriority="low""#),
9504 "excluding preloads must preserve the authored script"
9505 );
9506 }
9507
9508 #[test]
9509 fn boundary_duplicate_name_errors() {
9510 let mut parser = HtmlParser::new();
9511 let html = concat!(
9512 "<body>",
9513 r#"<boundary name="dup"><p>1</p></boundary>"#,
9514 r#"<boundary name="dup"><p>2</p></boundary>"#,
9515 "</body>",
9516 );
9517 let err = parser
9518 .parse("index.html", html)
9519 .expect_err("a duplicate boundary name must error");
9520 let ParserError::Template(diag) = err else {
9521 panic!("expected ParserError::Template, got {err:?}");
9522 };
9523 assert_eq!(diag.error_code(), Some(codes::DUPLICATE_BOUNDARY_NAME));
9524 }
9525
9526 #[test]
9527 fn boundary_nested_boundary_errors() {
9528 let mut parser = HtmlParser::new();
9529 let html = r#"<body><boundary name="outer"><boundary name="inner"><p>x</p></boundary></boundary></body>"#;
9530 let err = parser
9531 .parse("index.html", html)
9532 .expect_err("a nested boundary must error");
9533 let ParserError::Template(diag) = err else {
9534 panic!("expected ParserError::Template, got {err:?}");
9535 };
9536 assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9537 }
9538
9539 #[test]
9540 fn boundary_transitively_nested_through_component_errors() {
9541 let mut parser = HtmlParser::new();
9542 parser
9543 .component_registry
9544 .register_component(ComponentRegistration::new(
9545 "inner-panel",
9546 r#"<boundary name="inner"><p>x</p></boundary>"#,
9547 None,
9548 true,
9549 ))
9550 .expect("register");
9551 let err = parser
9552 .parse(
9553 "index.html",
9554 r#"<body><boundary name="outer"><inner-panel></inner-panel></boundary></body>"#,
9555 )
9556 .expect_err("transitively nested boundaries must error");
9557 let ParserError::Template(diag) = err else {
9558 panic!("expected ParserError::Template, got {err:?}");
9559 };
9560 assert_eq!(diag.error_code(), Some(codes::NESTED_BOUNDARY));
9561 assert!(diag
9562 .help_text()
9563 .is_some_and(|help| help.contains("runtime branches")));
9564 }
9565
9566 #[test]
9567 fn boundary_inside_if_preserves_false_branch_structure() {
9568 let mut parser = HtmlParser::new();
9569 let html =
9570 r#"<body><if condition="ready"><boundary name="x"><p>x</p></boundary></if></body>"#;
9571 parser.parse("index.html", html).expect("parse");
9572 let records = parser.into_fragment_records();
9573 let if_fragment_id = records["index.html"]
9574 .fragments
9575 .iter()
9576 .find_map(|fragment| match fragment.fragment.as_ref() {
9577 Some(Fragment::IfCond(if_cond)) => Some(if_cond.fragment_id.as_str()),
9578 _ => None,
9579 })
9580 .expect("if reference");
9581 let boundary = records[if_fragment_id]
9582 .fragments
9583 .iter()
9584 .find_map(|fragment| match fragment.fragment.as_ref() {
9585 Some(Fragment::Boundary(boundary)) => Some(boundary),
9586 _ => None,
9587 })
9588 .expect("boundary remains behind the runtime if");
9589 assert_eq!(boundary.owner_fragment_id, "index.html");
9590 assert!(!boundary.may_repeat);
9591 assert!(records["index.html"].contains_boundary);
9592 assert!(records[if_fragment_id].contains_boundary);
9593 assert_stream!(
9594 records,
9595 if_fragment_id,
9596 [
9597 boundary_matcher("x", 0),
9598 raw("<p>x</p>"),
9599 boundary_end_matcher(0),
9600 ]
9601 );
9602 }
9603
9604 #[test]
9605 fn boundary_directly_inside_for_is_rejected() {
9606 let mut parser = HtmlParser::new();
9607 let err = parser
9608 .parse(
9609 "index.html",
9610 r#"<body><for each="item in items"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></for></body>"#,
9611 )
9612 .expect_err("a boundary inside a repeat must error");
9613 let ParserError::Template(diag) = err else {
9614 panic!("expected ParserError::Template, got {err:?}");
9615 };
9616 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9617 assert!(
9618 diag.help_text()
9619 .is_some_and(|help| help.contains("wrap the whole <for>")),
9620 "{diag}"
9621 );
9622 }
9623
9624 #[test]
9625 fn boundary_under_if_inside_for_is_rejected() {
9626 let mut parser = HtmlParser::new();
9627 let err = parser
9628 .parse(
9629 "index.html",
9630 r#"<body><for each="item in items"><if condition="item.ready"><boundary name="x" key="{{item.id}}"><p>x</p></boundary></if></for></body>"#,
9631 )
9632 .expect_err("a boundary behind a runtime branch in a repeat must error");
9633 let ParserError::Template(diag) = err else {
9634 panic!("expected ParserError::Template, got {err:?}");
9635 };
9636 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9637 }
9638
9639 #[test]
9640 fn component_boundary_used_inside_for_is_rejected() {
9641 let mut parser = HtmlParser::new();
9642 parser
9643 .component_registry
9644 .register_component(ComponentRegistration::new(
9645 "row-item",
9646 r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9647 None,
9648 true,
9649 ))
9650 .expect("register");
9651 let err = parser
9652 .parse(
9653 "index.html",
9654 r#"<body><for each="item in items"><row-item></row-item></for></body>"#,
9655 )
9656 .expect_err("a component-local boundary reached from a repeat must error");
9657 let ParserError::Template(diag) = err else {
9658 panic!("expected ParserError::Template, got {err:?}");
9659 };
9660 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9661 assert!(diag.to_string().contains("row-ready"), "{diag}");
9664 assert_eq!(diag.component_name(), Some("index.html"));
9665 assert!(diag.to_string().contains("item in items"), "{diag}");
9666 }
9667
9668 #[test]
9669 fn transitive_repeat_boundary_diagnostic_uses_lowest_repeat_site() {
9670 for _ in 0..20 {
9671 let mut parser = HtmlParser::new();
9672 for (tag, template) in [
9673 (
9674 "left-list",
9675 r#"<for each="a in xs"><row-card></row-card></for>"#,
9676 ),
9677 (
9678 "right-list",
9679 r#"<for each="b in ys"><row-card></row-card></for>"#,
9680 ),
9681 (
9682 "row-card",
9683 r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9684 ),
9685 ] {
9686 parser
9687 .component_registry
9688 .register_component(ComponentRegistration::new(tag, template, None, true))
9689 .expect("register");
9690 }
9691 let err = parser
9692 .parse(
9693 "index.html",
9694 r#"<body><left-list></left-list><right-list></right-list></body>"#,
9695 )
9696 .expect_err("both repeats reach the same boundary declaration");
9697 let ParserError::Template(diag) = err else {
9698 panic!("expected ParserError::Template, got {err:?}");
9699 };
9700 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9701 assert_eq!(diag.component_name(), Some("left-list"));
9702 assert!(diag.to_string().contains("a in xs"), "{diag}");
9703 }
9704 }
9705
9706 #[test]
9707 fn route_boundary_reached_from_for_is_rejected() {
9708 let mut parser = HtmlParser::with_options(DomStrategy::Light);
9709 parser
9710 .component_registry
9711 .register_component(ComponentRegistration::new(
9712 "row-page",
9713 r#"<boundary name="row-ready"><p>row</p></boundary>"#,
9714 None,
9715 true,
9716 ))
9717 .expect("register");
9718 let err = parser
9719 .parse(
9720 "index.html",
9721 r#"<body><for each="item in items"><route path="/" component="row-page"></route></for></body>"#,
9722 )
9723 .expect_err("a route-mounted boundary inside a repeat must error");
9724 let ParserError::Template(diag) = err else {
9725 panic!("expected ParserError::Template, got {err:?}");
9726 };
9727 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9728 }
9729
9730 #[test]
9731 fn outlet_mounted_boundary_reached_from_for_is_rejected() {
9732 let mut parser = HtmlParser::with_options(DomStrategy::Light);
9736 for (tag, template) in [
9737 (
9738 "shell-page",
9739 r#"<div><for each="item in items"><outlet></outlet></for></div>"#,
9740 ),
9741 (
9742 "child-page",
9743 r#"<boundary name="child-ready"><p>child</p></boundary>"#,
9744 ),
9745 ] {
9746 parser
9747 .component_registry
9748 .register_component(ComponentRegistration::new(tag, template, None, true))
9749 .expect("register");
9750 }
9751 let err = parser
9752 .parse(
9753 "index.html",
9754 r#"<body><route path="/" component="shell-page"><route path="child" component="child-page"></route></route></body>"#,
9755 )
9756 .expect_err("an outlet-mounted boundary inside a repeat must error");
9757 let ParserError::Template(diag) = err else {
9758 panic!("expected ParserError::Template, got {err:?}");
9759 };
9760 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_IN_REPEAT));
9761 assert!(diag.to_string().contains("child-ready"), "{diag}");
9762 }
9763
9764 #[test]
9765 fn for_inside_one_boundary_is_allowed_and_stays_atomic() {
9766 let mut parser = HtmlParser::new();
9769 parser
9770 .parse(
9771 "index.html",
9772 r#"<body><boundary name="feed"><for each="item in items"><p>{{item.label}}</p></for></boundary></body>"#,
9773 )
9774 .expect("a repeat inside one boundary parses");
9775 let records = parser.into_fragment_records();
9776 assert!(records["index.html"].contains_boundary);
9777 assert!(
9778 !records["for-1"].contains_boundary,
9779 "the repeat body itself declares no boundary"
9780 );
9781 let boundary = records["index.html"]
9782 .fragments
9783 .iter()
9784 .find_map(|fragment| match fragment.fragment.as_ref() {
9785 Some(Fragment::Boundary(boundary)) => Some(boundary),
9786 _ => None,
9787 })
9788 .expect("boundary");
9789 assert!(!boundary.may_repeat);
9790 }
9791
9792 #[test]
9793 fn boundary_after_for_is_allowed() {
9794 let mut parser = HtmlParser::new();
9795 parser
9796 .parse(
9797 "index.html",
9798 r#"<body><for each="item in items"><p>{{item.label}}</p></for><boundary name="tail"><p>tail</p></boundary></body>"#,
9799 )
9800 .expect("a boundary following a repeat parses");
9801 let records = parser.into_fragment_records();
9802 assert!(records["index.html"].contains_boundary);
9803 assert!(!records["for-1"].contains_boundary);
9804 }
9805
9806 #[test]
9807 fn boundary_free_for_still_renders_without_boundary_metadata() {
9808 let mut parser = HtmlParser::new();
9809 parser
9810 .parse(
9811 "index.html",
9812 r#"<body><for each="item in items"><p>{{item.label}}</p></for></body>"#,
9813 )
9814 .expect("an ordinary repeat parses");
9815 let records = parser.into_fragment_records();
9816 assert!(!records["index.html"].contains_boundary);
9817 assert!(!records["for-1"].contains_boundary);
9818 }
9819
9820 #[test]
9821 fn empty_boundary_key_is_still_rejected() {
9822 let mut parser = HtmlParser::new();
9823 let err = parser
9824 .parse(
9825 "invalid.html",
9826 r#"<body><boundary name="x" key=""><p>x</p></boundary></body>"#,
9827 )
9828 .expect_err("an empty key must error");
9829 let ParserError::Template(diag) = err else {
9830 panic!("expected ParserError::Template, got {err:?}");
9831 };
9832 assert_eq!(diag.error_code(), Some(codes::INVALID_BOUNDARY_KEY));
9833 }
9834
9835 #[test]
9836 fn boundary_inside_reusable_component_is_component_local() {
9837 let mut parser = HtmlParser::new();
9838 parser
9839 .component_registry
9840 .register_component(ComponentRegistration::new(
9841 "my-widget",
9842 r#"<boundary name="x"><p>x</p></boundary>"#,
9843 None,
9844 true,
9845 ))
9846 .expect("register");
9847
9848 parser
9849 .parse(
9850 "index.html",
9851 r#"<body><my-widget></my-widget><boundary name="x"><p>entry</p></boundary></body>"#,
9852 )
9853 .expect("component-local boundary parses");
9854 let records = parser.into_fragment_records();
9855 let boundary = records["my-widget"]
9856 .fragments
9857 .iter()
9858 .find_map(|fragment| match fragment.fragment.as_ref() {
9859 Some(Fragment::Boundary(boundary)) => Some(boundary),
9860 _ => None,
9861 })
9862 .expect("component boundary");
9863 assert_eq!(boundary.owner_fragment_id, "my-widget");
9864 assert_eq!(boundary.name, "x");
9865 assert!(records["my-widget"].contains_boundary);
9866 assert!(records["index.html"].contains_boundary);
9867 let owners: HashSet<&str> = records
9868 .values()
9869 .flat_map(|list| list.fragments.iter())
9870 .filter_map(|fragment| match fragment.fragment.as_ref() {
9871 Some(Fragment::Boundary(boundary)) if boundary.name == "x" => {
9872 Some(boundary.owner_fragment_id.as_str())
9873 }
9874 _ => None,
9875 })
9876 .collect();
9877 assert_eq!(owners, HashSet::from(["index.html", "my-widget"]));
9878 }
9879
9880 #[test]
9881 fn component_boundary_repeated_from_static_callsites_requires_a_key() {
9882 let mut parser = HtmlParser::new();
9886 parser
9887 .component_registry
9888 .register_component(ComponentRegistration::new(
9889 "row-item",
9890 r#"<boundary name="row-ready"><span>{{label}}</span></boundary>"#,
9891 None,
9892 true,
9893 ))
9894 .expect("register");
9895 let err = parser
9896 .parse(
9897 "index.html",
9898 r#"<body><row-item></row-item><row-item></row-item></body>"#,
9899 )
9900 .expect_err("a repeated declaration without a key must error");
9901 let ParserError::Template(diag) = err else {
9902 panic!("expected ParserError::Template, got {err:?}");
9903 };
9904 assert_eq!(diag.error_code(), Some(codes::MISSING_BOUNDARY_KEY));
9905 assert_eq!(diag.component_name(), Some("row-item"));
9906
9907 let mut parser = HtmlParser::new();
9908 parser
9909 .component_registry
9910 .register_component(ComponentRegistration::new(
9911 "row-item",
9912 r#"<boundary name="row-ready" key="{{rowId}}"><span>{{label}}</span></boundary>"#,
9913 None,
9914 true,
9915 ))
9916 .expect("register");
9917 parser
9918 .parse(
9919 "index.html",
9920 r#"<body><row-item row-id="a"></row-item><row-item row-id="b"></row-item></body>"#,
9921 )
9922 .expect("a keyed repeated declaration parses");
9923 let records = parser.into_fragment_records();
9924 let boundary = records["row-item"]
9925 .fragments
9926 .iter()
9927 .find_map(|fragment| match fragment.fragment.as_ref() {
9928 Some(Fragment::Boundary(boundary)) => Some(boundary),
9929 _ => None,
9930 })
9931 .expect("component boundary");
9932 assert!(boundary.may_repeat);
9933 assert_eq!(boundary.key.as_deref(), Some("{{rowId}}"));
9934 }
9935
9936 #[test]
9937 fn component_boundary_once_per_independent_entry_is_not_repeatable() {
9938 let mut parser = HtmlParser::new();
9939 parser
9940 .component_registry
9941 .register_component(ComponentRegistration::new(
9942 "shared-panel",
9943 r#"<boundary name="ready"><span>x</span></boundary>"#,
9944 None,
9945 true,
9946 ))
9947 .expect("register");
9948 for entry in ["first.html", "second.html"] {
9949 parser
9950 .parse(entry, "<body><shared-panel></shared-panel></body>")
9951 .expect("entry parses");
9952 }
9953 let records = parser.into_fragment_records();
9954 let boundary = records["shared-panel"]
9955 .fragments
9956 .iter()
9957 .find_map(|fragment| match fragment.fragment.as_ref() {
9958 Some(Fragment::Boundary(boundary)) => Some(boundary),
9959 _ => None,
9960 })
9961 .expect("component boundary");
9962 assert!(!boundary.may_repeat);
9963 }
9964
9965 #[test]
9966 fn boundary_inside_component_host_content_errors() {
9967 let mut parser = HtmlParser::new();
9968 parser
9969 .component_registry
9970 .register_component(ComponentRegistration::new(
9971 "my-widget",
9972 "<div>content</div>",
9973 None,
9974 true,
9975 ))
9976 .expect("register");
9977
9978 let err = parser
9979 .parse(
9980 "index.html",
9981 r#"<body><my-widget><boundary name="x"><p>x</p></boundary></my-widget></body>"#,
9982 )
9983 .expect_err("a boundary inside component host content must error");
9984 let ParserError::Template(diag) = err else {
9985 panic!("expected ParserError::Template, got {err:?}");
9986 };
9987 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_CROSSES_SCOPE));
9988 assert!(
9989 diag.to_string().contains("component host content"),
9990 "{diag}"
9991 );
9992 }
9993
9994 #[test]
9995 fn boundary_inside_raw_or_inert_html_context_errors() {
9996 for (element, expected_scope) in [
9997 ("textarea", "<textarea> text content"),
9998 ("title", "<title> text content"),
9999 ("script", "<script> raw-text content"),
10000 ("xmp", "<xmp> raw-text content"),
10001 ("iframe", "<iframe> raw-text content"),
10002 ("noembed", "<noembed> raw-text content"),
10003 ("noframes", "<noframes> raw-text content"),
10004 ("noscript", "<noscript> inert content"),
10005 ("plaintext", "<plaintext> text content"),
10006 ("template", "<template> inert content"),
10007 ] {
10008 let mut parser = HtmlParser::new();
10009 let html = format!(
10010 r#"<body><{element}><boundary name="x"><p>x</p></boundary></{element}></body>"#
10011 );
10012 let err = parser
10013 .parse("index.html", &html)
10014 .expect_err("a boundary in raw or inert content must error");
10015 let ParserError::Template(diag) = err else {
10016 panic!("expected ParserError::Template, got {err:?}");
10017 };
10018 assert_eq!(
10019 diag.error_code(),
10020 Some(codes::BOUNDARY_CROSSES_SCOPE),
10021 "unexpected code for <{element}>"
10022 );
10023 assert!(
10024 diag.to_string().contains(expected_scope),
10025 "unexpected scope for <{element}>: {diag}"
10026 );
10027 }
10028 }
10029
10030 #[test]
10031 fn route_contained_boundary_has_runtime_content_fragment() {
10032 let mut parser = HtmlParser::with_options(DomStrategy::Light);
10033 parser
10034 .component_registry
10035 .register_component(ComponentRegistration::new(
10036 "home-page",
10037 "<h1>Home</h1>",
10038 None,
10039 true,
10040 ))
10041 .expect("register");
10042 parser
10043 .parse(
10044 "index.html",
10045 r#"<route path="/" component="home-page"><boundary name="route-ready"><aside>Ready</aside></boundary></route>"#,
10046 )
10047 .expect("route-contained boundary parses");
10048 let records = parser.into_fragment_records();
10049 assert!(records["index.html"].contains_boundary);
10050 let route = records["index.html"]
10051 .fragments
10052 .iter()
10053 .find_map(|fragment| match fragment.fragment.as_ref() {
10054 Some(Fragment::Route(route)) => Some(route),
10055 _ => None,
10056 })
10057 .expect("route");
10058 assert!(!route.content_fragment_id.is_empty());
10059 let boundary = records[&route.content_fragment_id]
10060 .fragments
10061 .iter()
10062 .find_map(|fragment| match fragment.fragment.as_ref() {
10063 Some(Fragment::Boundary(boundary)) => Some(boundary),
10064 _ => None,
10065 })
10066 .expect("route boundary");
10067 assert_eq!(boundary.owner_fragment_id, "index.html");
10068 assert_eq!(boundary.name, "route-ready");
10069 assert_stream!(
10070 records,
10071 route.content_fragment_id.as_str(),
10072 [
10073 boundary_matcher("route-ready", 0),
10074 raw("<aside>Ready</aside>"),
10075 boundary_end_matcher(0),
10076 ]
10077 );
10078 }
10079
10080 #[test]
10081 fn route_boundary_nested_in_ignored_markup_errors() {
10082 let mut parser = HtmlParser::new();
10083 let err = parser
10084 .parse(
10085 "index.html",
10086 r#"<route path="/" component="home-page"><div><boundary name="late"><p>x</p></boundary></div></route>"#,
10087 )
10088 .expect_err("route boundaries must be direct children");
10089 let ParserError::Template(diag) = err else {
10090 panic!("expected ParserError::Template, got {err:?}");
10091 };
10092 assert_eq!(
10093 diag.error_code(),
10094 Some(codes::INVALID_ROUTE_BOUNDARY_PLACEMENT)
10095 );
10096 }
10097
10098 #[test]
10099 fn boundary_must_be_inside_an_open_body() {
10100 for html in [
10101 r#"<boundary name="before"><p>x</p></boundary><body></body>"#,
10102 r#"<body></body><boundary name="after"><p>x</p></boundary>"#,
10103 ] {
10104 let mut parser = HtmlParser::new();
10105 let err = parser
10106 .parse("index.html", html)
10107 .expect_err("a boundary outside body must error");
10108 let ParserError::Template(diag) = err else {
10109 panic!("expected ParserError::Template, got {err:?}");
10110 };
10111 assert_eq!(diag.error_code(), Some(codes::BOUNDARY_OUTSIDE_BODY));
10112 assert_eq!(
10113 diag.help_text(),
10114 Some("move the <boundary> between the opening <body> and its matching </body>")
10115 );
10116 }
10117
10118 let mut parser = HtmlParser::new();
10119 parser
10120 .parse(
10121 "index.html",
10122 r#"<BODY><boundary name="inside"></boundary></BODY>"#,
10123 )
10124 .expect("mixed-case native body keeps the boundary inside an open body");
10125 }
10126
10127 #[test]
10128 fn boundary_inside_foster_parenting_context_errors() {
10129 for html in [
10133 r#"<body><table><boundary name="t"><p>x</p></boundary></table></body>"#,
10134 r#"<body><table><tbody><boundary name="t"><p>x</p></boundary></tbody></table></body>"#,
10135 r#"<body><table><thead><boundary name="t"><p>x</p></boundary></thead></table></body>"#,
10136 r#"<body><table><tfoot><boundary name="t"><p>x</p></boundary></tfoot></table></body>"#,
10137 r#"<body><table><tbody><tr><boundary name="t"><p>x</p></boundary></tr></tbody></table></body>"#,
10138 r#"<body><table><colgroup><boundary name="t"><p>x</p></boundary></colgroup></table></body>"#,
10139 r#"<body><select><boundary name="t"><option>x</option></boundary></select></body>"#,
10140 r#"<body><select><optgroup><boundary name="t"><option>x</option></boundary></optgroup></select></body>"#,
10141 r#"<body><TABLE><TBODY><boundary name="t"><p>x</p></boundary></TBODY></TABLE></body>"#,
10144 ] {
10145 let mut parser = HtmlParser::new();
10146 let err = parser
10147 .parse("index.html", html)
10148 .expect_err("a boundary in a foster-parenting context must error");
10149 let ParserError::Template(diag) = err else {
10150 panic!("expected ParserError::Template, got {err:?}");
10151 };
10152 assert_eq!(
10153 diag.error_code(),
10154 Some(codes::BOUNDARY_IN_FOSTER_CONTEXT),
10155 "unexpected code for {html}"
10156 );
10157 assert!(
10158 diag.help_text()
10159 .is_some_and(|help| help.contains("<td>, <th>, or <caption>")),
10160 "help should suggest a safe placement for {html}"
10161 );
10162 }
10163 }
10164
10165 #[test]
10166 fn boundary_is_allowed_where_insertion_mode_returns_to_in_body() {
10167 for html in [
10171 r#"<body><table><tbody><tr><td><boundary name="cell"><p>x</p></boundary></td></tr></tbody></table></body>"#,
10172 r#"<body><table><tbody><tr><th><boundary name="head"><p>x</p></boundary></th></tr></tbody></table></body>"#,
10173 r#"<body><table><caption><boundary name="cap"><p>x</p></boundary></caption></table></body>"#,
10174 r#"<body><boundary name="whole"><table><tbody><tr><td>x</td></tr></tbody></table></boundary></body>"#,
10176 concat!(
10180 "<body><table><tbody><tr><td>",
10181 "<table><tbody><tr><td>inner</td></tr></tbody></table>",
10182 r#"<boundary name="after-inner"><p>x</p></boundary>"#,
10183 "</td></tr></tbody></table></body>",
10184 ),
10185 r#"<body><table><tbody><tr><td><img src="a"><br><input></td></tr></tbody></table><boundary name="v"><p>x</p></boundary></body>"#,
10187 ] {
10188 let mut parser = HtmlParser::new();
10189 parser
10190 .parse("index.html", html)
10191 .unwrap_or_else(|err| panic!("{html} should parse, got {err:?}"));
10192 }
10193 }
10194
10195 #[test]
10196 fn foster_context_depth_is_restored_after_leaving_a_table() {
10197 let mut parser = HtmlParser::new();
10200 parser
10201 .parse(
10202 "index.html",
10203 concat!(
10204 "<body>",
10205 "<table><tbody><tr><td>x</td></tr></tbody></table>",
10206 r#"<boundary name="after-table"><p>y</p></boundary>"#,
10207 "</body>",
10208 ),
10209 )
10210 .expect("a boundary after a closed table must be accepted");
10211 }
10212
10213 #[test]
10214 fn authored_raw_bindings_cannot_forge_structural_signals() {
10215 let html = concat!(
10216 "<html><head></head><body>",
10217 "{{{head_start}}}{{{head_end}}}{{{body_start}}}{{{body_end}}}",
10218 "{{{boundary_start:0}}}{{{boundary_end:0}}}",
10219 "{{{streaming_root:forged}}}",
10220 "</body></html>",
10221 );
10222 let (fragments, _) = parse_and_get_fragments(html);
10223 let raw_signals: Vec<&str> = fragments
10224 .iter()
10225 .filter_map(|fragment| match fragment.fragment.as_ref() {
10226 Some(Fragment::Signal(signal)) if signal.raw => Some(signal.value.as_str()),
10227 _ => None,
10228 })
10229 .collect();
10230
10231 assert_eq!(
10232 raw_signals,
10233 [
10234 "}}}webui:head_start",
10235 "}}}webui:head_end",
10236 "}}}webui:body_start",
10237 "head_start",
10238 "head_end",
10239 "body_start",
10240 "body_end",
10241 "boundary_start:0",
10242 "boundary_end:0",
10243 "streaming_root:forged",
10244 "}}}webui:body_end",
10245 ]
10246 );
10247 }
10248
10249 #[test]
10250 fn authored_webui_hydrate_errors_at_top_level() {
10251 for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10252 let mut parser = HtmlParser::new();
10253 let html = format!("<div><{tag}></{tag}></div>");
10254 let err = parser
10255 .parse("index.html", &html)
10256 .expect_err("an authored <webui-hydrate> must error in any ASCII casing");
10257 let ParserError::Template(diag) = err else {
10258 panic!("expected ParserError::Template, got {err:?}");
10259 };
10260 assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10261 }
10262 }
10263
10264 #[test]
10265 fn authored_webui_hydrate_errors_inside_route() {
10266 for tag in ["webui-hydrate", "WEBUI-HYDRATE", "WebUi-HyDrAtE"] {
10267 let mut parser = HtmlParser::new();
10268 let html = format!(r#"<route path="/" component="home"><{tag}></{tag}></route>"#);
10269 let err = parser
10270 .parse("index.html", &html)
10271 .expect_err("an authored <webui-hydrate> inside <route> must error");
10272 let ParserError::Template(diag) = err else {
10273 panic!("expected ParserError::Template, got {err:?}");
10274 };
10275 assert_eq!(diag.error_code(), Some(codes::AUTHORED_WEBUI_HYDRATE));
10276 }
10277 }
10278
10279 #[test]
10280 fn boundary_directive_remains_case_sensitive() {
10281 let (fragments, _) =
10282 parse_and_get_fragments(r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#);
10283
10284 assert_fragments!(
10285 fragments,
10286 [raw(
10287 r#"<WEBUI-BOUNDARY name="ordinary"><p>x</p></WEBUI-BOUNDARY>"#
10288 ),]
10289 );
10290 }
10291
10292 #[test]
10293 fn templates_without_webui_boundary_are_unaffected() {
10294 let html = r#"<div><my-counter count="{{count}}"></my-counter></div>"#;
10298 let (fragments, _) = parse_and_get_fragments(html);
10299
10300 assert_fragments!(
10301 fragments,
10302 [
10303 raw("<div><my-counter"),
10304 attr("count", "count"),
10305 raw("></my-counter></div>"),
10306 ]
10307 );
10308 }
10309
10310 #[test]
10311 fn test_fail_with_invalid_markup() {
10312 let mut parser = HtmlParser::new();
10314 let result = parser.parse("index.html", "<div><span></div></span>");
10315
10316 assert!(matches!(result, Err(ParserError::Template(diag)) if
10317 diag.to_string().contains("unclosed <span> tag")
10318 ));
10319 }
10320
10321 #[test]
10322 fn test_complex_raw_text_page() {
10323 let html = concat!(
10326 "<!DOCTYPE html>",
10327 "<html lang=\"en\">",
10328 "<head>",
10329 "<meta charset=\"utf-8\">",
10330 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
10331 "<title>Complex Page</title>",
10332 "<style>body { margin: 0; padding: 0; } h1 { color: blue; }</style>",
10333 "<link rel=\"stylesheet\" href=\"styles.css\">",
10334 "</head>",
10335 "<body>",
10336 "<h1>Hello World</h1>",
10337 "<script type=\"module\" src=\"./app.js\"></script>",
10338 "</body>",
10339 "</html>",
10340 );
10341 let (fragments, _) = parse_and_get_fragments(html);
10342
10343 assert!(
10347 fragments.len() >= 9,
10348 "Expected at least 9 fragments, got {}",
10349 fragments.len()
10350 );
10351
10352 assert!(
10354 matches!(fragments[0].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10355 raw.value.contains("<!DOCTYPE html>") && raw.value.ends_with("<head>")),
10356 "First fragment should contain DOCTYPE and end with <head>, got: {:?}",
10357 fragments[0]
10358 );
10359
10360 assert!(
10362 matches!(fragments[1].fragment.as_ref(), Some(Fragment::Signal(s)) if
10363 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_start")),
10364 "Second fragment should be head_start signal"
10365 );
10366
10367 assert!(
10369 matches!(fragments[2].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10370 raw.value.contains("<meta charset=\"utf-8\">") &&
10371 raw.value.contains("<meta name=\"viewport\"") &&
10372 raw.value.contains("<title>Complex Page</title>") &&
10373 raw.value.contains("<style>") &&
10374 raw.value.contains("body { margin: 0; padding: 0; }")),
10375 "Third fragment should contain all head content, got: {:?}",
10376 fragments[2]
10377 );
10378
10379 assert!(
10381 matches!(fragments[3].fragment.as_ref(), Some(Fragment::Signal(s)) if
10382 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("head_end")),
10383 "Fourth fragment should be head_end signal"
10384 );
10385
10386 assert!(
10388 matches!(fragments[4].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10389 raw.value.contains("</head>") && raw.value.ends_with("<body>")),
10390 "Fifth fragment should contain </head><body>"
10391 );
10392
10393 assert!(
10395 matches!(fragments[5].fragment.as_ref(), Some(Fragment::Signal(s)) if
10396 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_start")),
10397 "Sixth fragment should be body_start signal"
10398 );
10399
10400 assert!(
10402 matches!(fragments[6].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10403 raw.value.contains("<h1>Hello World</h1>") &&
10404 raw.value.contains("<script")),
10405 "Seventh fragment should contain body content"
10406 );
10407
10408 assert!(
10410 matches!(fragments[7].fragment.as_ref(), Some(Fragment::Signal(s)) if
10411 s.raw && s.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX) == Some("body_end")),
10412 "Eighth fragment should be body_end signal"
10413 );
10414
10415 assert!(
10417 matches!(fragments[8].fragment.as_ref(), Some(Fragment::Raw(raw)) if
10418 raw.value.contains("</body>") && raw.value.contains("</html>")),
10419 "Ninth fragment should contain closing tags"
10420 );
10421 }
10422
10423 struct BindingCountPlugin {
10427 counts: Vec<u32>,
10428 }
10429
10430 impl BindingCountPlugin {
10431 fn new() -> Self {
10432 Self { counts: Vec::new() }
10433 }
10434 }
10435
10436 impl crate::plugin::ParserPlugin for BindingCountPlugin {
10437 fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10438 let attr_name = context.name;
10439 if attr_name.starts_with('@') || attr_name == "f-ref" {
10440 AttributeAction::SkipAndCountBinding
10441 } else {
10442 AttributeAction::Keep
10443 }
10444 }
10445
10446 fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
10447 let binding_attribute_count = context.binding_count;
10448 self.counts.push(binding_attribute_count);
10449 if binding_attribute_count > 0 {
10450 Some(binding_attribute_count.to_le_bytes().to_vec())
10451 } else {
10452 None
10453 }
10454 }
10455 }
10456
10457 struct TemplateCapturePlugin {
10458 template: Option<String>,
10459 uses_shadow_dom: bool,
10460 }
10461
10462 impl TemplateCapturePlugin {
10463 fn new() -> Self {
10464 Self {
10465 template: None,
10466 uses_shadow_dom: false,
10467 }
10468 }
10469 }
10470
10471 impl crate::plugin::ParserPlugin for TemplateCapturePlugin {
10472 fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
10473 self.template = Some(context.template.to_string());
10474 self.uses_shadow_dom = context.uses_shadow_dom;
10475 Ok(())
10476 }
10477
10478 fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
10479 let attr_name = context.name;
10480 if attr_name.starts_with('@') || attr_name == "f-ref" {
10481 AttributeAction::SkipAndCountBinding
10482 } else {
10483 AttributeAction::Keep
10484 }
10485 }
10486
10487 fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
10488 match self.template {
10489 Some(template) => Ok(ParserPluginArtifacts::ComponentTemplates(vec![
10490 crate::plugin::ComponentTemplateArtifact::template(
10491 "todo-app".to_string(),
10492 template,
10493 self.uses_shadow_dom,
10494 ),
10495 ])),
10496 None => Ok(ParserPluginArtifacts::None),
10497 }
10498 }
10499 }
10500
10501 #[test]
10502 fn parser_passes_user_template_root_attrs_to_plugin_without_css_modules() {
10503 let mut parser = HtmlParser::with_plugin_options(
10504 Box::new(TemplateCapturePlugin::new()),
10505 CssStrategy::Link,
10506 );
10507 parser
10508 .component_registry_mut()
10509 .register_component(ComponentRegistration::new(
10510 "todo-app",
10511 r#"<template shadowrootmode="open" @toggle-item="{onToggleItem($e)}" @delete-item="{onDeleteItem($e)}" f-ref="{root}"><div>items</div></template>"#,
10512 Some(":host { display: block; }"),
10513 true,))
10514 .expect("register todo-app");
10515
10516 parser
10517 .parse("index.html", "<todo-app></todo-app>")
10518 .expect("parse failed");
10519
10520 let artifacts = parser.take_plugin_artifacts().expect("artifacts");
10521 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10522 panic!("expected captured component template");
10523 };
10524 let template = &templates[0].template;
10525
10526 assert!(
10527 template.contains(r#"shadowrootmode="open""#),
10528 "parser must pass dev-authored root template attrs to plugins, got: {template}"
10529 );
10530 assert!(
10531 template.contains(r#"@toggle-item="{onToggleItem($e)}""#),
10532 "parser must pass dev-authored root event attrs to plugins, got: {template}"
10533 );
10534 assert!(
10535 template.contains(r#"@delete-item="{onDeleteItem($e)}""#),
10536 "parser must pass dev-authored root event attrs to plugins, got: {template}"
10537 );
10538 assert!(
10539 template.contains(r#"f-ref="{root}""#),
10540 "parser must pass dev-authored root FAST attrs to plugins, got: {template}"
10541 );
10542 assert!(
10543 !template.contains("shadowrootadoptedstylesheets"),
10544 "link CSS must not add CSS module adoption attrs, got: {template}"
10545 );
10546 }
10547
10548 #[test]
10549 fn test_component_static_attrs_not_counted_as_bindings() {
10550 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10551 parser
10552 .component_registry
10553 .register_component(ComponentRegistration::new(
10554 "my-btn",
10555 r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10556 None,
10557 true,
10558 ))
10559 .expect("register");
10560
10561 let html = r#"<my-btn class="primary" title="Click me">Go</my-btn>"#;
10563 let result = parser.parse("index.html", html);
10564 assert!(result.is_ok());
10565
10566 let records = parser.into_fragment_records();
10567
10568 let frags = &records["index.html"].fragments;
10570 let plugin_count = frags
10571 .iter()
10572 .filter(|f| matches!(f.fragment.as_ref(), Some(Fragment::Plugin(_))))
10573 .count();
10574 assert_eq!(
10575 plugin_count, 0,
10576 "Static-only component attributes should not emit a Plugin fragment"
10577 );
10578 }
10579
10580 #[test]
10581 fn test_component_dynamic_attr_counted_as_binding() {
10582 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10583 parser
10584 .component_registry
10585 .register_component(ComponentRegistration::new(
10586 "my-btn",
10587 r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10588 None,
10589 true,
10590 ))
10591 .expect("register");
10592
10593 let html = r#"<my-btn appearance="{{style}}">Go</my-btn>"#;
10595 let result = parser.parse("index.html", html);
10596 assert!(result.is_ok());
10597
10598 let records = parser.into_fragment_records();
10599 let frags = &records["index.html"].fragments;
10600
10601 let plugin_frags: Vec<_> = frags
10603 .iter()
10604 .filter_map(|f| match f.fragment.as_ref() {
10605 Some(Fragment::Plugin(p)) => Some(&p.data),
10606 _ => None,
10607 })
10608 .collect();
10609 assert_eq!(plugin_frags.len(), 1, "Expected 1 Plugin fragment");
10610 let count = u32::from_le_bytes([
10611 plugin_frags[0][0],
10612 plugin_frags[0][1],
10613 plugin_frags[0][2],
10614 plugin_frags[0][3],
10615 ]);
10616 assert_eq!(count, 1, "Binding attribute count should be 1");
10617 }
10618
10619 #[test]
10620 fn test_component_mixed_static_and_dynamic_attrs_binding_count() {
10621 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10622 parser
10623 .component_registry
10624 .register_component(ComponentRegistration::new(
10625 "my-btn",
10626 r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10627 None,
10628 true,
10629 ))
10630 .expect("register");
10631
10632 let html = r#"<my-btn class="primary" title="Submit" appearance="{{look}}" @click="{go}">Go</my-btn>"#;
10634 let result = parser.parse("index.html", html);
10635 assert!(result.is_ok());
10636
10637 let records = parser.into_fragment_records();
10638 let frags = &records["index.html"].fragments;
10639
10640 let plugin_frags: Vec<_> = frags
10641 .iter()
10642 .filter_map(|f| match f.fragment.as_ref() {
10643 Some(Fragment::Plugin(p)) => Some(&p.data),
10644 _ => None,
10645 })
10646 .collect();
10647 assert_eq!(plugin_frags.len(), 1);
10648 let count = u32::from_le_bytes([
10649 plugin_frags[0][0],
10650 plugin_frags[0][1],
10651 plugin_frags[0][2],
10652 plugin_frags[0][3],
10653 ]);
10654 assert_eq!(
10656 count, 2,
10657 "Binding count should include dynamic + plugin-skipped attrs, not static"
10658 );
10659 }
10660
10661 #[test]
10662 fn test_component_only_skipped_plugin_attrs_counted() {
10663 let mut parser = HtmlParser::with_plugin(Box::new(BindingCountPlugin::new()));
10664 parser
10665 .component_registry
10666 .register_component(ComponentRegistration::new(
10667 "my-btn",
10668 r#"<template shadowrootmode="open"><button><slot></slot></button></template>"#,
10669 None,
10670 true,
10671 ))
10672 .expect("register");
10673
10674 let html = r#"<my-btn title="Hello" @click="{go}" f-ref="{btn}">Go</my-btn>"#;
10676 let result = parser.parse("index.html", html);
10677 assert!(result.is_ok());
10678
10679 let records = parser.into_fragment_records();
10680 let frags = &records["index.html"].fragments;
10681
10682 let plugin_frags: Vec<_> = frags
10683 .iter()
10684 .filter_map(|f| match f.fragment.as_ref() {
10685 Some(Fragment::Plugin(p)) => Some(&p.data),
10686 _ => None,
10687 })
10688 .collect();
10689 assert_eq!(plugin_frags.len(), 1);
10690 let count = u32::from_le_bytes([
10691 plugin_frags[0][0],
10692 plugin_frags[0][1],
10693 plugin_frags[0][2],
10694 plugin_frags[0][3],
10695 ]);
10696 assert_eq!(
10698 count, 2,
10699 "Only plugin-skipped attrs should be counted, not static title"
10700 );
10701 }
10702
10703 #[test]
10706 fn test_comment_handlebars_signal_is_stripped() {
10707 let mut parser = HtmlParser::new();
10708 let html = "<!--{{tokens}}-->";
10709 parser.parse("test.html", html).expect("parse failed");
10710 let records = parser.into_fragment_records();
10711
10712 assert_stream!(records, "test.html", []);
10713 }
10714
10715 #[test]
10716 fn test_comment_triple_brace_raw_signal_is_stripped() {
10717 let mut parser = HtmlParser::new();
10718 let html = "<!--{{{tokens}}}-->";
10719 parser.parse("test.html", html).expect("parse failed");
10720 let records = parser.into_fragment_records();
10721
10722 assert_stream!(records, "test.html", []);
10723 }
10724
10725 #[test]
10726 fn test_comment_regular_stripped() {
10727 let mut parser = HtmlParser::new();
10728 let html = "<!-- regular comment -->";
10729 parser.parse("test.html", html).expect("parse failed");
10730 let records = parser.into_fragment_records();
10731
10732 assert_stream!(records, "test.html", []);
10733 }
10734
10735 #[test]
10736 fn test_comment_dotted_signal_is_stripped() {
10737 let mut parser = HtmlParser::new();
10738 let html = "<!--{{tokens.light}}-->";
10739 parser.parse("test.html", html).expect("parse failed");
10740 let records = parser.into_fragment_records();
10741
10742 assert_stream!(records, "test.html", []);
10743 }
10744
10745 #[test]
10746 fn test_comment_arbitrary_identifier_is_stripped() {
10747 let mut parser = HtmlParser::new();
10748 let html = "<!--{{someOtherBinding}}-->";
10749 parser.parse("test.html", html).expect("parse failed");
10750 let records = parser.into_fragment_records();
10751
10752 assert_stream!(records, "test.html", []);
10753 }
10754
10755 #[test]
10756 fn test_comment_with_surrounding_content_is_stripped() {
10757 let mut parser = HtmlParser::new();
10758 let html = "<div><!--{{tokens}}--></div>";
10759 parser.parse("test.html", html).expect("parse failed");
10760 let records = parser.into_fragment_records();
10761
10762 assert_stream!(records, "test.html", [raw("<div></div>")]);
10763 }
10764
10765 #[test]
10766 fn test_component_template_unterminated_comment_returns_error() {
10767 let mut parser = HtmlParser::new();
10768 parser
10769 .component_registry_mut()
10770 .register_component(ComponentRegistration::new(
10771 "x-bad",
10772 "<span>ok</span><!-- missing close",
10773 None,
10774 true,
10775 ))
10776 .expect("register failed");
10777
10778 let result = parser.parse("index.html", "<x-bad></x-bad>");
10779
10780 assert!(matches!(result, Err(ParserError::Template(diag)) if
10781 diag.to_string().contains("unterminated HTML comment")
10782 ));
10783 }
10784
10785 #[test]
10786 fn test_webui_plugin_strips_component_comments_before_metadata() {
10787 let mut parser =
10788 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10789 parser
10790 .component_registry_mut()
10791 .register_component(ComponentRegistration::new(
10792 "x-bleed",
10793 r#"<!-- {{path}} @click="{bad()}" ?hidden="{{bad}}" --><div>hello</div>"#,
10794 None,
10795 true,
10796 ))
10797 .expect("register failed");
10798
10799 parser
10800 .parse("index.html", "<x-bleed></x-bleed>")
10801 .expect("parse failed");
10802
10803 let artifacts = parser.take_plugin_artifacts().unwrap();
10804 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10805 panic!("expected component templates");
10806 };
10807 let template = &templates[0].template_json;
10808 assert!(template.contains("<div>hello</div>"));
10809 assert!(!template.contains("{{path}}"));
10810 assert!(!template.contains("@click"));
10811 assert!(!template.contains("?hidden"));
10812 assert!(!template.contains("-->"));
10813 }
10814
10815 #[test]
10816 fn test_webui_plugin_strips_component_style_line_comments_before_metadata() {
10817 let mut parser =
10818 HtmlParser::with_plugin(Box::new(crate::plugin::webui::WebUIParserPlugin::new()));
10819 parser
10820 .component_registry_mut()
10821 .register_component(ComponentRegistration::new(
10822 "x-style",
10823 "<style>// {{ignored}}\n.x { color: red; }</style><div>hello</div>",
10824 None,
10825 true,
10826 ))
10827 .expect("register failed");
10828
10829 parser
10830 .parse("index.html", "<x-style></x-style>")
10831 .expect("parse failed");
10832
10833 let artifacts = parser.take_plugin_artifacts().unwrap();
10834 let ParserPluginArtifacts::ComponentTemplates(templates) = artifacts else {
10835 panic!("expected component templates");
10836 };
10837 let template = &templates[0].template_json;
10838 assert!(template.contains(".x { color: red; }"));
10839 assert!(!template.contains("ignored"));
10840 assert!(!template.contains("//"));
10841 }
10842
10843 #[test]
10846 fn test_tokens_from_style_tag() {
10847 let mut parser = HtmlParser::new();
10848 let html = r#"<style>
10849 .btn { color: var(--colorPrimary); background: var(--bgColor); }
10850 </style>"#;
10851 parser.parse("test.html", html).expect("parse failed");
10852 let tokens = parser.take_tokens();
10853
10854 assert_eq!(tokens, vec!["bgColor", "colorPrimary"]);
10855 }
10856
10857 #[test]
10858 fn test_tokens_from_malformed_style_error_on_unclosed_var() {
10859 let mut parser = HtmlParser::new();
10860 let html = r#"<style>
10861 .bad { color: var(--dangling; }
10862 .ok { color: var(--valid); }
10863 </style>"#;
10864 let result = parser.parse("test.html", html);
10865
10866 assert!(matches!(result, Err(ParserError::Template(diag)) if
10867 diag.error_code() == Some(codes::INVALID_CSS)
10868 && diag.to_string().contains("Unterminated CSS var() call")
10869 ));
10870 }
10871
10872 #[test]
10873 fn test_tokens_from_component_css() {
10874 let mut parser = HtmlParser::new();
10875 parser
10876 .component_registry_mut()
10877 .register_component(ComponentRegistration::new(
10878 "my-button",
10879 "<button>Click</button>",
10880 Some(":host { color: var(--textColor); border: var(--borderWidth); }"),
10881 true,
10882 ))
10883 .expect("register failed");
10884
10885 let html = "<my-button></my-button>";
10886 parser.parse("test.html", html).expect("parse failed");
10887 let tokens = parser.take_tokens();
10888
10889 assert_eq!(tokens, vec!["borderWidth", "textColor"]);
10890 }
10891
10892 #[test]
10893 fn test_token_requirements_preserve_fallback_chains() {
10894 let mut parser = HtmlParser::new();
10895 parser
10896 .component_registry_mut()
10897 .register_component(ComponentRegistration::new(
10898 "my-card",
10899 "<div>Card</div>",
10900 Some(":host { color: var(--token-a, var(--token-b, var(--token-c), true)); }"),
10901 true,
10902 ))
10903 .expect("register failed");
10904
10905 parser
10906 .parse("test.html", "<my-card></my-card>")
10907 .expect("parse failed");
10908 let analysis = parser.token_analysis();
10909
10910 assert_eq!(analysis.fallback_chains.len(), 1);
10911 assert_eq!(
10912 analysis.fallback_chains[0].tokens,
10913 vec!["token-a", "token-b", "token-c"]
10914 );
10915 assert!(!analysis.fallback_chains[0].has_literal_fallback);
10916 }
10917
10918 #[test]
10919 fn test_token_requirements_remove_locally_defined_candidates_from_fallback_chain() {
10920 let mut parser = HtmlParser::new();
10921 parser
10922 .component_registry_mut()
10923 .register_component(ComponentRegistration::new(
10924 "my-card",
10925 "<div>Card</div>",
10926 Some(
10927 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10928 ),
10929 true,))
10930 .expect("register failed");
10931
10932 parser
10933 .parse("test.html", "<my-card></my-card>")
10934 .expect("parse failed");
10935 let analysis = parser.token_analysis();
10936
10937 assert_eq!(analysis.protocol_tokens, vec!["token-b", "token-c"]);
10938 assert_eq!(analysis.fallback_chains.len(), 1);
10939 assert_eq!(
10940 analysis.fallback_chains[0].tokens,
10941 vec!["token-b", "token-c"]
10942 );
10943 }
10944
10945 #[test]
10946 fn test_token_analysis_theme_validation_reports_missing_unresolved_token() {
10947 let mut parser = HtmlParser::new();
10948 parser
10949 .component_registry_mut()
10950 .register_component(ComponentRegistration::new(
10951 "my-card",
10952 "<div>Card</div>",
10953 Some(
10954 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c), true)); }",
10955 ),
10956 true,))
10957 .expect("register failed");
10958 parser
10959 .parse("test.html", "<my-card></my-card>")
10960 .expect("parse failed");
10961
10962 let analysis = parser.token_analysis();
10963 let theme = webui_tokens::TokenFile {
10964 themes: HashMap::from([(
10965 "light".to_string(),
10966 HashMap::from([("token-b".to_string(), "green".to_string())]),
10967 )]),
10968 };
10969
10970 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
10971 panic!("missing --token-c in the theme must fail parser validation");
10972 };
10973 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
10974 let help = diagnostic.help_text().expect("help text");
10977 assert!(help.contains("did you mean --token-b?"), "help: {help}");
10978 assert!(help.contains("define it locally"), "help: {help}");
10979 assert!(!help.contains("add --token"), "help: {help}");
10980 }
10981
10982 #[test]
10983 fn test_token_analysis_literal_fallback_token_exempt_from_theme_validation() {
10984 let mut parser = HtmlParser::new();
10985 parser
10986 .component_registry_mut()
10987 .register_component(ComponentRegistration::new(
10988 "my-card",
10989 "<div>Card</div>",
10990 Some(":host { color: var(--brand, #000); }"),
10991 true,
10992 ))
10993 .expect("register failed");
10994 parser
10995 .parse("test.html", "<my-card></my-card>")
10996 .expect("parse failed");
10997
10998 let analysis = parser.token_analysis();
10999 assert_eq!(analysis.protocol_tokens, vec!["brand"]);
11002
11003 let theme = webui_tokens::TokenFile {
11006 themes: HashMap::from([("light".to_string(), HashMap::new())]),
11007 };
11008 analysis
11009 .validate_theme_tokens(&theme)
11010 .expect("literal fallback must exempt --brand from theme validation");
11011 }
11012
11013 #[test]
11014 fn test_token_analysis_mixed_literal_and_bare_usage_requires_token() {
11015 let mut parser = HtmlParser::new();
11016 parser
11017 .component_registry_mut()
11018 .register_component(ComponentRegistration::new(
11019 "my-card",
11020 "<div>Card</div>",
11021 Some(":host { color: var(--brand, #000); background: var(--brand); }"),
11024 true,
11025 ))
11026 .expect("register failed");
11027 parser
11028 .parse("test.html", "<my-card></my-card>")
11029 .expect("parse failed");
11030
11031 let analysis = parser.token_analysis();
11032 let theme = webui_tokens::TokenFile {
11033 themes: HashMap::from([("light".to_string(), HashMap::new())]),
11034 };
11035
11036 let Err(ParserError::Template(diagnostic)) = analysis.validate_theme_tokens(&theme) else {
11037 panic!("a bare var(--brand) usage must require --brand in the theme");
11038 };
11039 assert_eq!(diagnostic.error_code(), Some(codes::MISSING_THEME_TOKEN));
11040 assert!(diagnostic.to_string().contains("--brand"));
11041 }
11042
11043 #[test]
11044 fn test_unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
11045 let mut parser = HtmlParser::new();
11046 parser
11047 .component_registry_mut()
11048 .register_component(ComponentRegistration::new(
11049 "my-card",
11050 "<div>Card</div>",
11051 Some(
11052 ":host { \
11053 color: var(--colr-brand, #000); \
11054 border: var(--present, 1px); \
11055 margin: var(--required); \
11056 }",
11057 ),
11058 true,
11059 ))
11060 .expect("register failed");
11061 parser
11062 .parse("test.html", "<my-card></my-card>")
11063 .expect("parse failed");
11064
11065 let analysis = parser.token_analysis();
11066 let theme = webui_tokens::TokenFile {
11067 themes: HashMap::from([(
11068 "light".to_string(),
11069 HashMap::from([
11070 ("present".to_string(), "2px".to_string()),
11071 ("required".to_string(), "8px".to_string()),
11072 ]),
11073 )]),
11074 };
11075
11076 assert_eq!(
11080 analysis.unthemed_literal_fallback_tokens(&theme),
11081 vec!["colr-brand".to_string()]
11082 );
11083 }
11084
11085 #[test]
11086 fn test_theme_token_error_reports_location_and_suggestion() {
11087 let mut parser = HtmlParser::new();
11088 parser
11089 .component_registry_mut()
11090 .register_component(ComponentRegistration::new(
11091 "my-card",
11092 "<div>Card</div>",
11093 ":host {\n color: var(--color-neutral-2000);\n}".into(),
11094 true,
11095 ))
11096 .expect("register failed");
11097 parser
11098 .parse("test.html", "<my-card></my-card>")
11099 .expect("parse failed");
11100
11101 let analysis = parser.token_analysis();
11102 let theme = webui_tokens::TokenFile {
11103 themes: HashMap::from([(
11104 "dark".to_string(),
11105 HashMap::from([("color-neutral-200".to_string(), "#222".to_string())]),
11106 )]),
11107 };
11108
11109 let Err(ParserError::Template(diag)) = analysis.validate_theme_tokens(&theme) else {
11110 panic!("missing --color-neutral-2000 must fail validation");
11111 };
11112 assert_eq!(diag.error_code(), Some(codes::MISSING_THEME_TOKEN));
11113 let location = diag.location().expect("a source location");
11115 assert!(
11116 location.contains("my-card.css:2:14"),
11117 "location: {location}"
11118 );
11119 assert!(
11121 diag.snippet_text()
11122 .is_some_and(|s| s.contains("--color-neutral-2000")),
11123 "snippet: {:?}",
11124 diag.snippet_text()
11125 );
11126 assert!(
11128 diag.help_text()
11129 .is_some_and(|h| h.contains("did you mean --color-neutral-200?")),
11130 "help: {:?}",
11131 diag.help_text()
11132 );
11133 }
11134
11135 #[test]
11136 fn test_theme_token_warning_reports_location_and_suggestion() {
11137 let mut parser = HtmlParser::new();
11138 parser
11139 .component_registry_mut()
11140 .register_component(ComponentRegistration::new(
11141 "my-card",
11142 "<div>Card</div>",
11143 ":host {\n color: var(--colr-brand, #000);\n}".into(),
11144 true,
11145 ))
11146 .expect("register failed");
11147 parser
11148 .parse("test.html", "<my-card></my-card>")
11149 .expect("parse failed");
11150
11151 let analysis = parser.token_analysis();
11152 let theme = webui_tokens::TokenFile {
11153 themes: HashMap::from([(
11154 "light".to_string(),
11155 HashMap::from([("color-brand".to_string(), "#abc".to_string())]),
11156 )]),
11157 };
11158
11159 let warnings = analysis.theme_token_warnings(&theme);
11160 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
11161 let body = warnings[0].body();
11162 assert!(body.contains("my-card.css:2:"), "warning: {body}");
11163 assert!(body.contains("--colr-brand"), "warning: {body}");
11164 assert!(
11165 body.contains("did you mean --color-brand?"),
11166 "warning: {body}"
11167 );
11168 }
11169
11170 #[test]
11171 fn test_tokens_from_malformed_component_css_error_on_unclosed_var() {
11172 let mut parser = HtmlParser::new();
11173 let result =
11174 parser
11175 .component_registry_mut()
11176 .register_component(ComponentRegistration::new(
11177 "my-card",
11178 "<div>Card</div>",
11179 Some(".bad { color: var(--dangling; } .ok { color: var(--valid); }"),
11180 true,
11181 ));
11182
11183 assert!(matches!(result, Err(ParserError::Css(message)) if
11184 message.contains("Unterminated CSS var() call")
11185 ));
11186 }
11187
11188 #[test]
11189 fn test_tokens_merged_from_style_and_components() {
11190 let mut parser = HtmlParser::new();
11191 parser
11192 .component_registry_mut()
11193 .register_component(ComponentRegistration::new(
11194 "my-widget",
11195 "<div>Widget</div>",
11196 Some(".w { padding: var(--spacingM); }"),
11197 true,
11198 ))
11199 .expect("register failed");
11200
11201 let html = r#"<style>.root { color: var(--textColor); }</style><my-widget></my-widget>"#;
11202 parser.parse("test.html", html).expect("parse failed");
11203 let tokens = parser.take_tokens();
11204
11205 assert_eq!(tokens, vec!["spacingM", "textColor"]);
11206 }
11207
11208 #[test]
11209 fn test_tokens_deduplicated() {
11210 let mut parser = HtmlParser::new();
11211 parser
11212 .component_registry_mut()
11213 .register_component(ComponentRegistration::new(
11214 "my-btn",
11215 "<button>B</button>",
11216 Some(".b { color: var(--shared); }"),
11217 true,
11218 ))
11219 .expect("register failed");
11220
11221 let html = r#"<style>.x { color: var(--shared); }</style><my-btn></my-btn>"#;
11222 parser.parse("test.html", html).expect("parse failed");
11223 let tokens = parser.take_tokens();
11224
11225 assert_eq!(tokens, vec!["shared"]);
11226 }
11227
11228 #[test]
11229 fn test_tokens_empty_when_no_vars() {
11230 let mut parser = HtmlParser::new();
11231 let html = "<div>Hello</div>";
11232 parser.parse("test.html", html).expect("parse failed");
11233 let tokens = parser.take_tokens();
11234
11235 assert!(tokens.is_empty());
11236 }
11237
11238 #[test]
11239 fn test_tokens_exclude_locally_defined_in_component() {
11240 let mut parser = HtmlParser::new();
11241 parser
11242 .component_registry_mut()
11243 .register_component(ComponentRegistration::new(
11244 "my-card",
11245 "<div>Card</div>",
11246 Some(":host { --local: 5px; width: var(--external); }"),
11247 true,
11248 ))
11249 .expect("register failed");
11250
11251 let html = "<my-card></my-card>";
11252 parser.parse("test.html", html).expect("parse failed");
11253 let tokens = parser.take_tokens();
11254
11255 assert_eq!(tokens, vec!["external"]);
11256 }
11257
11258 #[test]
11259 fn test_tokens_exclude_entry_root_definitions() {
11260 let mut parser = HtmlParser::new();
11261 parser
11262 .component_registry_mut()
11263 .register_component(ComponentRegistration::new(
11264 "my-btn",
11265 "<button>B</button>",
11266 Some(".b { color: var(--color-primary); border-radius: var(--radius-m); }"),
11267 true,
11268 ))
11269 .expect("register failed");
11270
11271 let html = r#"<style>
11274 :root {
11275 --color-primary: #0078d4;
11276 --radius-m: 6px;
11277 }
11278 body { color: var(--color-primary); }
11279 </style>
11280 <my-btn></my-btn>"#;
11281 parser.parse("test.html", html).expect("parse failed");
11282 let tokens = parser.take_tokens();
11283
11284 assert!(
11286 tokens.is_empty(),
11287 "Tokens defined in entry :root should be excluded: {tokens:?}"
11288 );
11289 }
11290
11291 #[test]
11292 fn test_tokens_entry_defs_exclude_but_external_kept() {
11293 let mut parser = HtmlParser::new();
11294 parser
11295 .component_registry_mut()
11296 .register_component(ComponentRegistration::new(
11297 "my-card",
11298 "<div>Card</div>",
11299 Some(".c { color: var(--color-primary); margin: var(--external-spacing); }"),
11300 true,
11301 ))
11302 .expect("register failed");
11303
11304 let html = r#"<style>
11306 :root { --color-primary: #0078d4; }
11307 </style>
11308 <my-card></my-card>"#;
11309 parser.parse("test.html", html).expect("parse failed");
11310 let tokens = parser.take_tokens();
11311
11312 assert_eq!(tokens, vec!["external-spacing"]);
11314 }
11315
11316 #[test]
11319 fn test_parse_simple_route() {
11320 let mut parser = HtmlParser::new();
11321 let html = r#"<route path="/profile" component="profile-page" exact />"#;
11322 parser.parse("test.html", html).expect("parse failed");
11323
11324 let records = parser.into_fragment_records();
11326 let frags = &records["test.html"].fragments;
11327 assert_eq!(frags.len(), 1);
11328 match frags[0].fragment.as_ref() {
11329 Some(web_ui_fragment::Fragment::Route(r)) => {
11330 assert_eq!(r.path, "/profile");
11331 assert_eq!(r.fragment_id, "profile-page");
11332 assert!(r.exact);
11333 }
11334 other => panic!("expected Fragment::Route, got {:?}", other),
11335 }
11336 }
11337
11338 #[test]
11339 fn test_parse_route_with_params() {
11340 let mut parser = HtmlParser::new();
11341 let html = r#"<route path="/profile/:id/view/:section" component="detail" />"#;
11342 parser.parse("test.html", html).expect("parse failed");
11343
11344 let records = parser.into_fragment_records();
11346 let frags = &records["test.html"].fragments;
11347 match frags[0].fragment.as_ref() {
11348 Some(web_ui_fragment::Fragment::Route(r)) => {
11349 assert_eq!(r.path, "/profile/:id/view/:section");
11350 }
11351 other => panic!("expected Fragment::Route, got {:?}", other),
11352 }
11353 }
11354
11355 #[test]
11356 fn test_parse_route_requires_component() {
11357 let mut parser = HtmlParser::new();
11358 let html = r#"<route path="/old" />"#;
11359 let result = parser.parse("test.html", html);
11360 assert!(result.is_err());
11361 }
11362
11363 #[test]
11364 fn test_parse_multiple_routes() {
11365 let mut parser = HtmlParser::new();
11366 let html = r#"<route path="/" component="app-layout" />
11367 <route path="/dashboard" component="dash-page" exact />
11368 <route path="/contacts" component="contacts-page" exact />"#;
11369 parser.parse("test.html", html).expect("parse failed");
11370
11371 let records = parser.into_fragment_records();
11372 let frags = &records["test.html"].fragments;
11373 assert_eq!(frags.len(), 3);
11374 for frag in frags {
11376 assert!(matches!(
11377 frag.fragment.as_ref(),
11378 Some(web_ui_fragment::Fragment::Route(_))
11379 ));
11380 }
11381 }
11382
11383 #[test]
11384 fn test_parse_route_requires_component_with_body() {
11385 let mut parser = HtmlParser::new();
11386 let html = r#"<route path="/error">
11387 <div class="error"><h1>Not Found</h1></div>
11388 </route>"#;
11389 let result = parser.parse("test.html", html);
11390 assert!(
11391 result.is_err(),
11392 "Route without component attribute should fail"
11393 );
11394 }
11395
11396 #[test]
11397 fn test_parse_multiple_routes_with_fragments() {
11398 let mut parser = HtmlParser::new();
11399 let html = r#"<route path="/" component="home-page" exact />
11400 <route path="/about" component="about-page" exact />
11401 <route path="/contact/:id" component="contact-page" />"#;
11402 parser.parse("test.html", html).expect("parse failed");
11403
11404 let records = parser.into_fragment_records();
11405 let frags = &records["test.html"].fragments;
11406 assert_eq!(frags.len(), 3);
11407
11408 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[0].fragment.as_ref() {
11410 assert_eq!(r.path, "/");
11411 }
11412 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[1].fragment.as_ref() {
11413 assert_eq!(r.path, "/about");
11414 }
11415 if let Some(web_ui_fragment::Fragment::Route(r)) = frags[2].fragment.as_ref() {
11416 assert_eq!(r.path, "/contact/:id");
11417 }
11418 }
11419
11420 #[test]
11421 fn test_parse_route_with_query_allowlist() {
11422 let mut parser = HtmlParser::new();
11423 let html =
11424 r#"<route path="/compose" component="compose-page" query="action,to,subject" exact />"#;
11425 parser.parse("test.html", html).expect("parse failed");
11426
11427 let records = parser.into_fragment_records();
11428 let frags = &records["test.html"].fragments;
11429 assert_eq!(frags.len(), 1);
11430 match frags[0].fragment.as_ref() {
11431 Some(web_ui_fragment::Fragment::Route(r)) => {
11432 assert_eq!(r.path, "/compose");
11433 assert_eq!(r.fragment_id, "compose-page");
11434 assert!(r.exact);
11435 assert_eq!(r.allowed_query, "action,to,subject");
11436 }
11437 other => panic!("expected Fragment::Route, got {:?}", other),
11438 }
11439 }
11440
11441 #[test]
11442 fn test_parse_route_without_query_has_empty_allowed_query() {
11443 let mut parser = HtmlParser::new();
11444 let html = r#"<route path="/profile" component="profile-page" exact />"#;
11445 parser.parse("test.html", html).expect("parse failed");
11446
11447 let records = parser.into_fragment_records();
11448 let frags = &records["test.html"].fragments;
11449 match frags[0].fragment.as_ref() {
11450 Some(web_ui_fragment::Fragment::Route(r)) => {
11451 assert!(r.allowed_query.is_empty());
11452 }
11453 other => panic!("expected Fragment::Route, got {:?}", other),
11454 }
11455 }
11456
11457 #[test]
11458 fn test_route_body_rejects_malformed_non_route_html() {
11459 let mut parser = HtmlParser::new();
11460 let html = r#"<route path="/" component="home-page"><div><span></div></route>"#;
11461 let result = parser.parse("test.html", html);
11462
11463 assert!(matches!(result, Err(ParserError::Template(diag)) if
11464 diag.to_string().contains("unclosed <span> tag")
11465 ));
11466 }
11467
11468 #[test]
11469 fn test_route_body_rejects_malformed_style_css() {
11470 let mut parser = HtmlParser::new();
11471 let html = r#"<route path="/" component="home-page"><style>.bad { color: var(--x; }</style></route>"#;
11472 let result = parser.parse("test.html", html);
11473
11474 assert!(matches!(result, Err(ParserError::Template(diag)) if
11475 diag.error_code() == Some(codes::INVALID_CSS)
11476 && diag.to_string().contains("Unterminated CSS var() call")
11477 ));
11478 }
11479
11480 #[test]
11481 fn test_outlet_not_captured_by_for_loop() {
11482 let mut parser = HtmlParser::new();
11483 let html = r#"<template shadowrootmode="open">
11484 <ul>
11485 <for each="item in items">
11486 <li>{{item.name}}</li>
11487 </for>
11488 </ul>
11489 <main>
11490 <outlet />
11491 </main>
11492</template>"#;
11493 parser.parse("comp.html", html).expect("parse failed");
11494
11495 let records = parser.into_fragment_records();
11496 let frags = &records["comp.html"].fragments;
11497
11498 let outlet_count = frags
11500 .iter()
11501 .filter(|f| {
11502 matches!(
11503 f.fragment.as_ref(),
11504 Some(web_ui_fragment::Fragment::Outlet(_))
11505 )
11506 })
11507 .count();
11508 assert_eq!(
11509 outlet_count, 1,
11510 "expected exactly 1 outlet in top-level fragments, got {outlet_count}"
11511 );
11512
11513 let outlet_idx = frags
11515 .iter()
11516 .position(|f| {
11517 matches!(
11518 f.fragment.as_ref(),
11519 Some(web_ui_fragment::Fragment::Outlet(_))
11520 )
11521 })
11522 .expect("no outlet found");
11523 let close_ul_idx = frags.iter().position(|f| match f.fragment.as_ref() {
11524 Some(web_ui_fragment::Fragment::Raw(r)) => r.value.contains("</ul>"),
11525 _ => false,
11526 });
11527 if let Some(ul_idx) = close_ul_idx {
11528 assert!(
11529 outlet_idx > ul_idx,
11530 "outlet (at {outlet_idx}) should come after </ul> (at {ul_idx})"
11531 );
11532 }
11533 }
11534
11535 #[test]
11536 fn test_outlet_position_after_for_not_inside() {
11537 let mut parser = HtmlParser::new();
11538 let html = r#"<ul><for each="x in items"><li>ok</li></for></ul><outlet />"#;
11539 parser.parse("test.html", html).expect("parse failed");
11540
11541 let records = parser.into_fragment_records();
11542 let frags = &records["test.html"].fragments;
11543
11544 let has_outlet = frags.iter().any(|f| {
11546 matches!(
11547 f.fragment.as_ref(),
11548 Some(web_ui_fragment::Fragment::Outlet(_))
11549 )
11550 });
11551 assert!(
11552 has_outlet,
11553 "outlet should be in top-level fragments: {frags:?}"
11554 );
11555
11556 let for_id = frags.iter().find_map(|f| match f.fragment.as_ref() {
11558 Some(web_ui_fragment::Fragment::ForLoop(fl)) => Some(fl.fragment_id.clone()),
11559 _ => None,
11560 });
11561 if let Some(id) = for_id {
11562 let for_frags = &records[&id].fragments;
11563 let outlet_in_for = for_frags.iter().any(|f| {
11564 matches!(
11565 f.fragment.as_ref(),
11566 Some(web_ui_fragment::Fragment::Outlet(_))
11567 )
11568 });
11569 assert!(
11570 !outlet_in_for,
11571 "outlet should NOT be inside for-loop body: {for_frags:?}"
11572 );
11573 }
11574 }
11575
11576 #[test]
11577 fn multiple_outlets_in_one_fragment_warn() {
11578 let mut parser = HtmlParser::new();
11579 let html = r#"<main><outlet /></main><aside><outlet /></aside>"#;
11580 parser.parse("test.html", html).expect("parse failed");
11581
11582 let warnings = parser.take_warnings();
11583 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
11584 assert_eq!(warnings[0].severity(), Severity::Warning);
11585 assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
11586 assert_eq!(warnings[0].component_name(), Some("test.html"));
11587 assert!(warnings[0].help_text().is_some());
11588 }
11589
11590 #[test]
11591 fn multiple_outlets_across_nested_directives_warn() {
11592 for html in [
11593 r#"<if condition="show"><outlet /></if><outlet />"#,
11594 r#"<for each="item in items"><outlet /></for><outlet />"#,
11595 ] {
11596 let mut parser = HtmlParser::new();
11597 parser.parse("test.html", html).expect("parse failed");
11598
11599 let warnings = parser.take_warnings();
11600 assert_eq!(warnings.len(), 1, "warnings for {html}: {warnings:?}");
11601 assert_eq!(warnings[0].error_code(), Some(codes::MULTIPLE_OUTLETS));
11602 }
11603 }
11604
11605 #[test]
11606 fn test_style_element_with_handlebars_signal_comment_emits_signal() {
11607 let mut parser = HtmlParser::new();
11608 let html = r#"<html><head><style>
11609:root {
11610 /*{{{tokens.light}}}*/
11611}
11612</style></head><body></body></html>"#;
11613
11614 let result = parser.parse("test.html", html);
11615 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11616 let fragment_records = parser.into_fragment_records();
11617
11618 assert_stream!(
11619 fragment_records,
11620 "test.html",
11621 [
11622 raw("<html><head>"),
11623 structural_matcher("head_start"),
11624 raw("<style>\n:root {\n "),
11625 raw_text_signal("tokens.light", true),
11626 raw("\n}\n</style>"),
11627 structural_matcher("head_end"),
11628 raw("</head><body>"),
11629 structural_matcher("body_start"),
11630 structural_matcher("body_end"),
11631 raw("</body></html>"),
11632 ]
11633 );
11634 }
11635
11636 #[test]
11637 fn test_style_comment_signal_with_spaces_emits_signal() {
11638 let mut parser = HtmlParser::new();
11639 let html = r#"<html><head><style>
11640:root {
11641 /* {{{tokens.light}}} */
11642}
11643</style></head><body></body></html>"#;
11644
11645 let result = parser.parse("test.html", html);
11646 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11647 let fragment_records = parser.into_fragment_records();
11648
11649 assert_stream!(
11650 fragment_records,
11651 "test.html",
11652 [
11653 raw("<html><head>"),
11654 structural_matcher("head_start"),
11655 raw("<style>\n:root {\n "),
11656 raw_text_signal("tokens.light", true),
11657 raw("\n}\n</style>"),
11658 structural_matcher("head_end"),
11659 raw("</head><body>"),
11660 structural_matcher("body_start"),
11661 structural_matcher("body_end"),
11662 raw("</body></html>"),
11663 ]
11664 );
11665 }
11666
11667 #[test]
11668 fn test_style_comment_signal_double_brace_emits_signal() {
11669 let mut parser = HtmlParser::new();
11670 let html = "<html><head><style>/*{{themeCss}}*/</style></head><body></body></html>";
11671
11672 let result = parser.parse("test.html", html);
11673 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11674 let fragment_records = parser.into_fragment_records();
11675
11676 assert_stream!(
11677 fragment_records,
11678 "test.html",
11679 [
11680 raw("<html><head>"),
11681 structural_matcher("head_start"),
11682 raw("<style>"),
11683 raw_text_signal("themeCss", false),
11684 raw("</style>"),
11685 structural_matcher("head_end"),
11686 raw("</head><body>"),
11687 structural_matcher("body_start"),
11688 structural_matcher("body_end"),
11689 raw("</body></html>"),
11690 ]
11691 );
11692 }
11693
11694 #[test]
11695 fn test_style_comment_with_extra_text_is_stripped() {
11696 let mut parser = HtmlParser::new();
11697 let html = "<html><head><style>/* theme: {{token}} */</style></head><body></body></html>";
11698
11699 let result = parser.parse("test.html", html);
11700 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11701 let fragment_records = parser.into_fragment_records();
11702
11703 assert_stream!(
11704 fragment_records,
11705 "test.html",
11706 [
11707 raw("<html><head>"),
11708 structural_matcher("head_start"),
11709 raw("<style></style>"),
11710 structural_matcher("head_end"),
11711 raw("</head><body>"),
11712 structural_matcher("body_start"),
11713 structural_matcher("body_end"),
11714 raw("</body></html>"),
11715 ]
11716 );
11717 }
11718
11719 #[test]
11720 fn test_style_comment_with_multiple_signals_is_stripped() {
11721 let mut parser = HtmlParser::new();
11722 let html = "<html><head><style>/*{{a}}{{b}}*/</style></head><body></body></html>";
11723
11724 let result = parser.parse("test.html", html);
11725 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11726 let fragment_records = parser.into_fragment_records();
11727
11728 assert_stream!(
11729 fragment_records,
11730 "test.html",
11731 [
11732 raw("<html><head>"),
11733 structural_matcher("head_start"),
11734 raw("<style></style>"),
11735 structural_matcher("head_end"),
11736 raw("</head><body>"),
11737 structural_matcher("body_start"),
11738 structural_matcher("body_end"),
11739 raw("</body></html>"),
11740 ]
11741 );
11742 }
11743
11744 #[test]
11745 fn test_style_bare_handlebars_stays_raw() {
11746 let mut parser = HtmlParser::new();
11747 let html =
11749 "<html><head><style>body { color: {{textColor}}; }</style></head><body></body></html>";
11750
11751 let result = parser.parse("test.html", html);
11752 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11753 let fragment_records = parser.into_fragment_records();
11754
11755 assert_stream!(
11756 fragment_records,
11757 "test.html",
11758 [
11759 raw("<html><head>"),
11760 structural_matcher("head_start"),
11761 raw("<style>body { color: {{textColor}}; }</style>"),
11762 structural_matcher("head_end"),
11763 raw("</head><body>"),
11764 structural_matcher("body_start"),
11765 structural_matcher("body_end"),
11766 raw("</body></html>"),
11767 ]
11768 );
11769 }
11770
11771 #[test]
11772 fn test_style_mixed_css_and_comment_signal_emits_signal() {
11773 let mut parser = HtmlParser::new();
11774 let html = r#"<html><head><style>
11775 .a { color: red; }
11776 /*{{themeCss}}*/
11777 .b { color: blue; }
11778</style></head><body></body></html>"#;
11779
11780 let result = parser.parse("test.html", html);
11781 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11782 let fragment_records = parser.into_fragment_records();
11783
11784 assert_stream!(
11785 fragment_records,
11786 "test.html",
11787 [
11788 raw("<html><head>"),
11789 structural_matcher("head_start"),
11790 raw("<style>\n .a { color: red; }\n "),
11791 raw_text_signal("themeCss", false),
11792 raw("\n .b { color: blue; }\n</style>"),
11793 structural_matcher("head_end"),
11794 raw("</head><body>"),
11795 structural_matcher("body_start"),
11796 structural_matcher("body_end"),
11797 raw("</body></html>"),
11798 ]
11799 );
11800 }
11801
11802 #[test]
11803 fn test_style_legal_comment_preserved_inline_by_default() {
11804 let mut parser = HtmlParser::new();
11805 let html = "<html><head><style>/*! @license MIT */ .x { color: red; } /* remove */</style></head><body></body></html>";
11806
11807 parser.parse("test.html", html).expect("parse failed");
11808 let fragment_records = parser.into_fragment_records();
11809
11810 assert_stream!(
11811 fragment_records,
11812 "test.html",
11813 [
11814 raw("<html><head>"),
11815 structural_matcher("head_start"),
11816 raw("<style>/*! @license MIT */ .x { color: red; } </style>"),
11817 structural_matcher("head_end"),
11818 raw("</head><body>"),
11819 structural_matcher("body_start"),
11820 structural_matcher("body_end"),
11821 raw("</body></html>"),
11822 ]
11823 );
11824 }
11825
11826 #[test]
11827 fn test_style_legal_comments_none_strips_legal_comment() {
11828 let mut parser = HtmlParser::with_options(ParserOptions {
11829 legal_comments: LegalComments::None,
11830 ..ParserOptions::default()
11831 });
11832 let html = "<html><head><style>/*! @license MIT */ .x { color: red; }</style></head><body></body></html>";
11833
11834 parser.parse("test.html", html).expect("parse failed");
11835 let fragment_records = parser.into_fragment_records();
11836
11837 assert_stream!(
11838 fragment_records,
11839 "test.html",
11840 [
11841 raw("<html><head>"),
11842 structural_matcher("head_start"),
11843 raw("<style> .x { color: red; }</style>"),
11844 structural_matcher("head_end"),
11845 raw("</head><body>"),
11846 structural_matcher("body_start"),
11847 structural_matcher("body_end"),
11848 raw("</body></html>"),
11849 ]
11850 );
11851 }
11852
11853 #[test]
11854 fn test_style_element_plain_css_unchanged() {
11855 let mut parser = HtmlParser::new();
11856 let html = "<html><head><style>body { margin: 0; }</style></head><body></body></html>";
11857
11858 let result = parser.parse("test.html", html);
11859 assert!(result.is_ok(), "Parse error: {:?}", result.err());
11860 let fragment_records = parser.into_fragment_records();
11861
11862 assert_stream!(
11864 fragment_records,
11865 "test.html",
11866 [
11867 raw("<html><head>"),
11868 structural_matcher("head_start"),
11869 raw("<style>body { margin: 0; }</style>"),
11870 structural_matcher("head_end"),
11871 raw("</head><body>"),
11872 structural_matcher("body_start"),
11873 structural_matcher("body_end"),
11874 raw("</body></html>"),
11875 ]
11876 );
11877 }
11878
11879 #[test]
11880 fn test_unclosed_style_element_returns_error() {
11881 let mut parser = HtmlParser::new();
11882 let result = parser.parse("index.html", "<style>.x { color: red; ");
11883 assert!(matches!(result, Err(ParserError::Template(diag)) if
11884 diag.to_string().contains("unclosed <style> tag")
11885 ));
11886 }
11887
11888 #[test]
11889 fn test_text_with_binding_after_void_element() {
11890 let (fragments, _) = parse_and_get_fragments(
11893 "Part A: {{value1}}<button>Click</button><br>Part B: {{value2}}<button>Click2</button>",
11894 );
11895 assert_fragments!(
11896 fragments,
11897 [
11898 raw("Part A: "),
11899 signal("value1"),
11900 raw("<button>Click</button><br>Part B: "),
11901 signal("value2"),
11902 raw("<button>Click2</button>"),
11903 ]
11904 );
11905 }
11906}