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