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