1use std::collections::BTreeMap;
2
3use crate::{
4 AttributeValue, ComponentInvocationEntity, ComponentInvocationId,
5 ComponentInvocationResolutionStatus, ElementNode, FragmentNode, SemanticId,
6 SlotContentFragmentId, SlotEntity, SlotId, SlotOutletId, SourceProvenance, TemplateChild,
7 TemplateNode,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SlotContentFragmentStatus {
12 Resolved,
13 Blocked,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub enum SlotContentFragmentViolation {
18 UnresolvedInvocationTarget,
19 MissingSlotDeclaration,
20 DuplicateFragment,
21 InvalidNestedWrapper,
22 UnsupportedDynamicSlotName,
23 InvalidWrapperForm,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SlotContentFragment {
29 pub id: SlotContentFragmentId,
30 pub owner_component: SemanticId,
31 pub invocation: ComponentInvocationId,
32 pub requested_slot_name: String,
33 pub slot: Option<SlotId>,
34 pub template_fragment_root: SemanticId,
35 pub content_template_entities: Vec<SemanticId>,
36 pub status: SlotContentFragmentStatus,
37 pub violations: Vec<SlotContentFragmentViolation>,
38 pub provenance: SourceProvenance,
39 pub secondary_provenances: Vec<SourceProvenance>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum SlotOutletStatus {
44 Resolved,
45 Blocked,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
49pub enum SlotOutletViolation {
50 MissingSlotDeclaration,
51 DuplicateOutlet,
52 UnsupportedDynamicSlotName,
53 InvalidOutletAttributes,
54 UnsupportedFallback,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct SlotOutlet {
60 pub id: SlotOutletId,
61 pub owner_component: SemanticId,
62 pub slot: Option<SlotId>,
63 pub requested_slot_name: String,
64 pub template_entity: SemanticId,
65 pub status: SlotOutletStatus,
66 pub violations: Vec<SlotOutletViolation>,
67 pub provenance: SourceProvenance,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct SlotCompositionRegistry {
72 pub fragments: BTreeMap<SlotContentFragmentId, SlotContentFragment>,
73 pub outlets: BTreeMap<SlotOutletId, SlotOutlet>,
74}
75
76#[derive(Debug, Clone)]
77struct RawFragment {
78 invocation: ComponentInvocationId,
79 requested_slot_name: String,
80 identity_name: String,
81 content_template_entities: Vec<SemanticId>,
82 violations: Vec<SlotContentFragmentViolation>,
83 provenance: SourceProvenance,
84}
85
86#[must_use]
88pub fn collect_slot_composition(
89 templates: &[TemplateNode],
90 invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
91 slots: &BTreeMap<SlotId, SlotEntity>,
92) -> SlotCompositionRegistry {
93 let mut raw_fragments = Vec::new();
94 let mut outlets = Vec::new();
95
96 for template in templates {
97 if let Some(root) = &template.root {
98 collect_element_facts(
99 template,
100 root,
101 "root",
102 None,
103 false,
104 invocations,
105 slots,
106 &mut raw_fragments,
107 &mut outlets,
108 );
109 }
110 if let Some(root) = &template.root_fragment {
111 collect_fragment_facts(
112 template,
113 root,
114 "root",
115 None,
116 false,
117 invocations,
118 slots,
119 &mut raw_fragments,
120 &mut outlets,
121 );
122 }
123 }
124
125 let fragments = finalize_fragments(raw_fragments, invocations, slots);
126 mark_duplicate_outlets(&mut outlets);
127 SlotCompositionRegistry {
128 fragments,
129 outlets: outlets
130 .into_iter()
131 .map(|outlet| (outlet.id.clone(), outlet))
132 .collect(),
133 }
134}
135
136#[allow(clippy::too_many_arguments)]
137fn collect_element_facts(
138 template: &TemplateNode,
139 element: &ElementNode,
140 path: &str,
141 nearest_invocation: Option<&ComponentInvocationEntity>,
142 direct_child_of_invocation: bool,
143 invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
144 slots: &BTreeMap<SlotId, SlotEntity>,
145 raw_fragments: &mut Vec<RawFragment>,
146 outlets: &mut Vec<SlotOutlet>,
147) {
148 let entity_id = template.id.template_entity("element", path);
149 let invocation_here = invocations
150 .values()
151 .find(|invocation| invocation.template_entity == entity_id);
152 let active_invocation = invocation_here.or(nearest_invocation);
153
154 if let Some(invocation) = invocation_here {
155 collect_direct_invocation_content(template, element, path, invocation, raw_fragments);
156 } else if element.tag_name == "template"
157 && has_attribute(element, "slot")
158 && !direct_child_of_invocation
159 {
160 if let Some(nearest_invocation) = nearest_invocation {
161 raw_fragments.push(raw_wrapper_fragment(
162 template,
163 element,
164 path,
165 nearest_invocation,
166 true,
167 ));
168 }
169 }
170
171 if element.tag_name == "slot" {
172 outlets.push(lower_outlet(template, element, path, slots));
173 }
174
175 for (index, child) in element.children.iter().enumerate() {
176 let child_path = format!("{path}.{index}");
177 collect_child_facts(
178 template,
179 child,
180 &child_path,
181 active_invocation,
182 invocation_here.is_some(),
183 invocations,
184 slots,
185 raw_fragments,
186 outlets,
187 );
188 }
189}
190
191#[allow(clippy::too_many_arguments)]
192fn collect_fragment_facts(
193 template: &TemplateNode,
194 fragment: &FragmentNode,
195 path: &str,
196 nearest_invocation: Option<&ComponentInvocationEntity>,
197 direct_child_of_invocation: bool,
198 invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
199 slots: &BTreeMap<SlotId, SlotEntity>,
200 raw_fragments: &mut Vec<RawFragment>,
201 outlets: &mut Vec<SlotOutlet>,
202) {
203 for (index, child) in fragment.children.iter().enumerate() {
204 collect_child_facts(
205 template,
206 child,
207 &format!("{path}.{index}"),
208 nearest_invocation,
209 direct_child_of_invocation,
210 invocations,
211 slots,
212 raw_fragments,
213 outlets,
214 );
215 }
216}
217
218#[allow(clippy::too_many_arguments)]
219fn collect_child_facts(
220 template: &TemplateNode,
221 child: &TemplateChild,
222 path: &str,
223 nearest_invocation: Option<&ComponentInvocationEntity>,
224 direct_child_of_invocation: bool,
225 invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
226 slots: &BTreeMap<SlotId, SlotEntity>,
227 raw_fragments: &mut Vec<RawFragment>,
228 outlets: &mut Vec<SlotOutlet>,
229) {
230 match child {
231 TemplateChild::Element(element) => collect_element_facts(
232 template,
233 element,
234 path,
235 nearest_invocation,
236 direct_child_of_invocation,
237 invocations,
238 slots,
239 raw_fragments,
240 outlets,
241 ),
242 TemplateChild::Fragment(fragment) => collect_fragment_facts(
243 template,
244 fragment,
245 path,
246 nearest_invocation,
247 direct_child_of_invocation,
248 invocations,
249 slots,
250 raw_fragments,
251 outlets,
252 ),
253 TemplateChild::Conditional(conditional) => {
254 for (index, child) in conditional.when_true.iter().enumerate() {
255 collect_child_facts(
256 template,
257 child,
258 &format!("{path}.true.{index}"),
259 nearest_invocation,
260 false,
261 invocations,
262 slots,
263 raw_fragments,
264 outlets,
265 );
266 }
267 for (index, child) in conditional.when_false.iter().enumerate() {
268 collect_child_facts(
269 template,
270 child,
271 &format!("{path}.false.{index}"),
272 nearest_invocation,
273 false,
274 invocations,
275 slots,
276 raw_fragments,
277 outlets,
278 );
279 }
280 }
281 TemplateChild::List(list) => {
282 for (index, child) in list.item_template.iter().enumerate() {
283 collect_child_facts(
284 template,
285 child,
286 &format!("{path}.item.{index}"),
287 nearest_invocation,
288 false,
289 invocations,
290 slots,
291 raw_fragments,
292 outlets,
293 );
294 }
295 }
296 TemplateChild::Text { .. } | TemplateChild::Binding { .. } => {}
297 }
298}
299
300fn collect_direct_invocation_content(
301 template: &TemplateNode,
302 element: &ElementNode,
303 path: &str,
304 invocation: &ComponentInvocationEntity,
305 raw_fragments: &mut Vec<RawFragment>,
306) {
307 let mut default_entities = Vec::new();
308 let mut default_span = None;
309
310 for (index, child) in element.children.iter().enumerate() {
311 let child_path = format!("{path}.{index}");
312 let exact_wrapper = match child {
313 TemplateChild::Element(element)
314 if element.tag_name == "template" && has_attribute(element, "slot") =>
315 {
316 let raw = raw_wrapper_fragment(template, element, &child_path, invocation, false);
317 let is_exact = raw.violations.is_empty();
318 raw_fragments.push(raw);
319 is_exact
320 }
321 _ => false,
322 };
323 if exact_wrapper {
324 continue;
325 }
326 default_entities.push(child_entity_id(&template.id, child, &child_path));
327 default_span.get_or_insert_with(|| child_span(child));
328 }
329
330 if !default_entities.is_empty() {
331 raw_fragments.push(RawFragment {
332 invocation: invocation.id.clone(),
333 requested_slot_name: "children".to_string(),
334 identity_name: "children".to_string(),
335 content_template_entities: default_entities,
336 violations: Vec::new(),
337 provenance: SourceProvenance::new(
338 &template.provenance.path,
339 default_span.expect("non-empty default content has a span"),
340 ),
341 });
342 }
343}
344
345#[allow(clippy::single_match_else)]
346fn raw_wrapper_fragment(
347 template: &TemplateNode,
348 element: &ElementNode,
349 path: &str,
350 invocation: &ComponentInvocationEntity,
351 nested: bool,
352) -> RawFragment {
353 let slot_attributes = element
354 .attributes
355 .iter()
356 .filter(|attribute| attribute.name == "slot")
357 .collect::<Vec<_>>();
358 let mut violations = Vec::new();
359 let (requested_slot_name, identity_name) = match slot_attributes.as_slice() {
360 [attribute] => match &attribute.value {
361 AttributeValue::Static(name) if !name.is_empty() => (name.clone(), name.clone()),
362 AttributeValue::Binding { expression, .. } => {
363 violations.push(SlotContentFragmentViolation::UnsupportedDynamicSlotName);
364 (
365 expression.clone(),
366 format!("dynamic:{}", template.id.template_entity("element", path)),
367 )
368 }
369 _ => {
370 violations.push(SlotContentFragmentViolation::InvalidWrapperForm);
371 (
372 "<invalid>".to_string(),
373 format!("invalid:{}", template.id.template_entity("element", path)),
374 )
375 }
376 },
377 _ => {
378 violations.push(SlotContentFragmentViolation::InvalidWrapperForm);
379 (
380 "<invalid>".to_string(),
381 format!("invalid:{}", template.id.template_entity("element", path)),
382 )
383 }
384 };
385 if element.attributes.len() != 1 || nested {
386 violations.push(if nested {
387 SlotContentFragmentViolation::InvalidNestedWrapper
388 } else {
389 SlotContentFragmentViolation::InvalidWrapperForm
390 });
391 }
392 violations.sort_unstable();
393 violations.dedup();
394
395 RawFragment {
396 invocation: invocation.id.clone(),
397 requested_slot_name,
398 identity_name,
399 content_template_entities: element
400 .children
401 .iter()
402 .enumerate()
403 .map(|(index, child)| child_entity_id(&template.id, child, &format!("{path}.{index}")))
404 .collect(),
405 violations,
406 provenance: SourceProvenance::new(&template.provenance.path, element.tag_name_span),
407 }
408}
409
410fn finalize_fragments(
411 raw_fragments: Vec<RawFragment>,
412 invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
413 slots: &BTreeMap<SlotId, SlotEntity>,
414) -> BTreeMap<SlotContentFragmentId, SlotContentFragment> {
415 let mut grouped = BTreeMap::<(ComponentInvocationId, String), Vec<RawFragment>>::new();
416 for raw in raw_fragments {
417 grouped
418 .entry((raw.invocation.clone(), raw.identity_name.clone()))
419 .or_default()
420 .push(raw);
421 }
422
423 grouped
424 .into_iter()
425 .map(|((invocation_id, identity_name), mut candidates)| {
426 candidates.sort_by(|left, right| {
427 (
428 left.provenance.path.as_path(),
429 left.provenance.span.start,
430 &left.requested_slot_name,
431 )
432 .cmp(&(
433 right.provenance.path.as_path(),
434 right.provenance.span.start,
435 &right.requested_slot_name,
436 ))
437 });
438 let first = candidates.remove(0);
439 let invocation = invocations
440 .get(&invocation_id)
441 .expect("raw fragments originate from canonical invocations");
442 let mut violations = first.violations.clone();
443 if !candidates.is_empty() {
444 violations.push(SlotContentFragmentViolation::DuplicateFragment);
445 }
446 let slot = invocation.target_component.as_ref().and_then(|target| {
447 let id = SlotId::for_component(target, &first.requested_slot_name);
448 slots.contains_key(&id).then_some(id)
449 });
450 if invocation.status != ComponentInvocationResolutionStatus::Resolved {
451 violations.push(SlotContentFragmentViolation::UnresolvedInvocationTarget);
452 } else if slot.is_none()
453 && !violations.contains(&SlotContentFragmentViolation::UnsupportedDynamicSlotName)
454 && !violations.contains(&SlotContentFragmentViolation::InvalidWrapperForm)
455 {
456 violations.push(SlotContentFragmentViolation::MissingSlotDeclaration);
457 }
458 violations.sort_unstable();
459 violations.dedup();
460 let id = SlotContentFragmentId::for_invocation(&invocation_id, &identity_name);
461 let template_fragment_root = id
462 .as_semantic_id()
463 .template_entity("fragment-root", "content");
464 let fragment = SlotContentFragment {
465 id: id.clone(),
466 owner_component: invocation.owner_component.clone(),
467 invocation: invocation_id,
468 requested_slot_name: first.requested_slot_name,
469 slot,
470 template_fragment_root,
471 content_template_entities: first.content_template_entities,
472 status: if violations.is_empty() {
473 SlotContentFragmentStatus::Resolved
474 } else {
475 SlotContentFragmentStatus::Blocked
476 },
477 violations,
478 provenance: first.provenance,
479 secondary_provenances: candidates
480 .into_iter()
481 .map(|candidate| candidate.provenance)
482 .collect(),
483 };
484 (id, fragment)
485 })
486 .collect()
487}
488
489fn lower_outlet(
490 template: &TemplateNode,
491 element: &ElementNode,
492 path: &str,
493 slots: &BTreeMap<SlotId, SlotEntity>,
494) -> SlotOutlet {
495 let owner_component = template
496 .owner
497 .entity_id()
498 .expect("component templates have component owners")
499 .clone();
500 let mut violations = Vec::new();
501 let (requested_slot_name, identity_name) = match element.attributes.as_slice() {
502 [] => ("children".to_string(), "children".to_string()),
503 [attribute] if attribute.name == "name" => match &attribute.value {
504 AttributeValue::Static(name) if !name.is_empty() => (name.clone(), name.clone()),
505 AttributeValue::Binding { expression, .. } => {
506 violations.push(SlotOutletViolation::UnsupportedDynamicSlotName);
507 (expression.clone(), format!("dynamic:{path}"))
508 }
509 _ => {
510 violations.push(SlotOutletViolation::InvalidOutletAttributes);
511 ("<invalid>".to_string(), format!("invalid:{path}"))
512 }
513 },
514 _ => {
515 violations.push(SlotOutletViolation::InvalidOutletAttributes);
516 ("<invalid>".to_string(), format!("invalid:{path}"))
517 }
518 };
519 if !element.children.is_empty() {
520 violations.push(SlotOutletViolation::UnsupportedFallback);
521 }
522 let slot_id = SlotId::for_component(&owner_component, &requested_slot_name);
523 let slot = slots.contains_key(&slot_id).then_some(slot_id);
524 if slot.is_none()
525 && !violations.contains(&SlotOutletViolation::UnsupportedDynamicSlotName)
526 && !violations.contains(&SlotOutletViolation::InvalidOutletAttributes)
527 {
528 violations.push(SlotOutletViolation::MissingSlotDeclaration);
529 }
530 violations.sort_unstable();
531 violations.dedup();
532 let template_entity = template.id.template_entity("element", path);
533 let id = SlotOutletId::for_template_entity(&template_entity, &identity_name);
534
535 SlotOutlet {
536 id,
537 owner_component,
538 slot,
539 requested_slot_name,
540 template_entity,
541 status: if violations.is_empty() {
542 SlotOutletStatus::Resolved
543 } else {
544 SlotOutletStatus::Blocked
545 },
546 violations,
547 provenance: SourceProvenance::new(&template.provenance.path, element.tag_name_span),
548 }
549}
550
551fn mark_duplicate_outlets(outlets: &mut [SlotOutlet]) {
552 let mut counts = BTreeMap::<(SemanticId, String), usize>::new();
553 for outlet in outlets.iter() {
554 *counts
555 .entry((
556 outlet.owner_component.clone(),
557 outlet.requested_slot_name.clone(),
558 ))
559 .or_default() += 1;
560 }
561 for outlet in outlets {
562 if counts[&(
563 outlet.owner_component.clone(),
564 outlet.requested_slot_name.clone(),
565 )] > 1
566 {
567 outlet.violations.push(SlotOutletViolation::DuplicateOutlet);
568 outlet.violations.sort_unstable();
569 outlet.violations.dedup();
570 outlet.status = SlotOutletStatus::Blocked;
571 }
572 }
573}
574
575fn has_attribute(element: &ElementNode, name: &str) -> bool {
576 element
577 .attributes
578 .iter()
579 .any(|attribute| attribute.name == name)
580}
581
582fn child_entity_id(template: &SemanticId, child: &TemplateChild, path: &str) -> SemanticId {
583 template.template_entity(
584 match child {
585 TemplateChild::Text { .. } => "text",
586 TemplateChild::Binding { .. } => "binding",
587 TemplateChild::Element(_) => "element",
588 TemplateChild::Fragment(_) => "fragment",
589 TemplateChild::Conditional(_) => "conditional",
590 TemplateChild::List(_) => "list",
591 },
592 path,
593 )
594}
595
596const fn child_span(child: &TemplateChild) -> presolve_parser::SourceSpan {
597 match child {
598 TemplateChild::Text { span, .. } | TemplateChild::Binding { span, .. } => *span,
599 TemplateChild::Element(element) => element.span,
600 TemplateChild::Fragment(fragment) => fragment.span,
601 TemplateChild::Conditional(conditional) => conditional.span,
602 TemplateChild::List(list) => list.span,
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use crate::{
609 build_application_semantic_model, build_semantic_graph,
610 validate_application_semantic_model, ComponentInvocationResolutionStatus,
611 SemanticEntityKind, SemanticOwner, SlotContentFragmentStatus, SlotContentFragmentViolation,
612 SlotKind, SlotOutletStatus, SlotOutletViolation,
613 };
614
615 #[test]
616 fn lowers_default_and_named_content_with_caller_ownership() {
617 let asm = build_application_semantic_model(&presolve_parser::parse_file(
618 "src/Composition.tsx",
619 r#"
620@component("x-card") class Card extends Component {
621 @slot() children!: SlotContent;
622 @slot() header!: SlotContent;
623 render() { return <article><slot name="header" /><slot /></article>; }
624}
625@component("x-page") class Page extends Component {
626 render() {
627 return <Card>before<span>body</span><template slot="header"><h1>Title</h1></template>after</Card>;
628 }
629}
630"#,
631 ));
632 let invocation = asm.component_invocations().into_iter().next().unwrap();
633 assert_eq!(
634 invocation.status,
635 ComponentInvocationResolutionStatus::Resolved
636 );
637 let fragments = asm.slot_content_fragments_for(&invocation.id);
638 assert_eq!(fragments.len(), 2);
639 let children = fragments
640 .iter()
641 .find(|fragment| fragment.requested_slot_name == "children")
642 .unwrap();
643 let header = fragments
644 .iter()
645 .find(|fragment| fragment.requested_slot_name == "header")
646 .unwrap();
647 assert_eq!(children.status, SlotContentFragmentStatus::Resolved);
648 assert_eq!(header.status, SlotContentFragmentStatus::Resolved);
649 assert_eq!(children.content_template_entities.len(), 3);
650 assert_eq!(header.content_template_entities.len(), 1);
651 assert_eq!(children.owner_component, invocation.owner_component);
652 assert_eq!(header.owner_component, invocation.owner_component);
653 assert_eq!(
654 asm.owner(&header.content_template_entities[0]),
655 Some(&SemanticOwner::entity(
656 invocation.owner_component.template()
657 ))
658 );
659 assert_eq!(header.slot.as_ref().unwrap(), &asm.slots()[1].id);
660 assert!(fragments.iter().all(|fragment| {
661 fragment
662 .slot
663 .as_ref()
664 .and_then(|slot| asm.slot(slot))
665 .is_some_and(|slot| matches!(slot.kind, SlotKind::Default | SlotKind::Named))
666 }));
667 assert_eq!(asm.slot_outlets().len(), 2);
668 assert!(asm
669 .slot_outlets()
670 .iter()
671 .all(|outlet| outlet.status == SlotOutletStatus::Resolved));
672 assert!(asm.slot_outlets().iter().all(|outlet| {
673 asm.owner(outlet.id.as_semantic_id())
674 == Some(&SemanticOwner::entity(outlet.owner_component.clone()))
675 && asm
676 .entity(outlet.id.as_semantic_id())
677 .is_some_and(|entity| entity.kind() == SemanticEntityKind::SlotOutlet)
678 }));
679 assert!(
680 validate_application_semantic_model(&asm).is_empty(),
681 "canonical Slot composition should pass ASM validation"
682 );
683 let graph = build_semantic_graph(&asm);
684 assert_eq!(graph.schema_version, 6);
685 assert!(graph.nodes.iter().all(|node| {
686 !asm.slot_content_fragments
687 .keys()
688 .any(|fragment| node.id == *fragment.as_semantic_id())
689 && !asm
690 .slot_outlets
691 .keys()
692 .any(|outlet| node.id == *outlet.as_semantic_id())
693 }));
694 }
695
696 #[test]
697 fn retains_empty_missing_dynamic_nested_and_duplicate_fragment_facts() {
698 let asm = build_application_semantic_model(&presolve_parser::parse_file(
699 "src/InvalidContent.tsx",
700 r#"
701@component("x-card") class Card extends Component {
702 @slot() children!: SlotContent;
703 @slot() header!: SlotContent;
704 render() { return <article />; }
705}
706@component("x-page") class Page extends Component {
707 render() {
708 return <><Card /><Card><template slot="header" /><template slot="header" /></Card><Card><template slot="missing" /></Card><Card><template slot={this.name} /></Card><Card><div><template slot="header" /></div></Card></>;
709 }
710}
711"#,
712 ));
713 let invocations = asm.component_invocations();
714 assert_eq!(invocations.len(), 5);
715 assert!(asm
716 .slot_content_fragments_for(&invocations[0].id)
717 .is_empty());
718 let all = asm.slot_content_fragments();
719 assert!(all.iter().any(|fragment| fragment
720 .violations
721 .contains(&SlotContentFragmentViolation::DuplicateFragment)));
722 assert!(all.iter().any(|fragment| fragment
723 .violations
724 .contains(&SlotContentFragmentViolation::MissingSlotDeclaration)));
725 assert!(all.iter().any(|fragment| fragment
726 .violations
727 .contains(&SlotContentFragmentViolation::UnsupportedDynamicSlotName)));
728 assert!(all.iter().any(|fragment| fragment
729 .violations
730 .contains(&SlotContentFragmentViolation::InvalidNestedWrapper)));
731 assert!(all
732 .iter()
733 .filter(|fragment| fragment.requested_slot_name == "header")
734 .any(|fragment| !fragment.secondary_provenances.is_empty()));
735 }
736
737 #[test]
738 fn retains_missing_dynamic_duplicate_and_fallback_outlets() {
739 let asm = build_application_semantic_model(&presolve_parser::parse_file(
740 "src/InvalidOutlets.tsx",
741 r#"
742@component("x-panel") class Panel extends Component {
743 @slot() children!: SlotContent;
744 @slot() title!: SlotContent;
745 render() { return <main><slot /><slot /><slot name="missing" /><slot name={this.name} /><slot name="title">fallback</slot></main>; }
746}
747"#,
748 ));
749 let outlets = asm.slot_outlets();
750 assert_eq!(outlets.len(), 5);
751 assert!(outlets
752 .iter()
753 .filter(|outlet| outlet.requested_slot_name == "children")
754 .all(|outlet| outlet
755 .violations
756 .contains(&SlotOutletViolation::DuplicateOutlet)
757 && outlet.status == SlotOutletStatus::Blocked));
758 assert!(outlets.iter().any(|outlet| outlet
759 .violations
760 .contains(&SlotOutletViolation::MissingSlotDeclaration)));
761 assert!(outlets.iter().any(|outlet| outlet
762 .violations
763 .contains(&SlotOutletViolation::UnsupportedDynamicSlotName)));
764 assert!(outlets.iter().any(|outlet| outlet
765 .violations
766 .contains(&SlotOutletViolation::UnsupportedFallback)));
767 }
768
769 #[test]
770 fn keeps_ordinary_template_elements_as_default_content() {
771 let asm = build_application_semantic_model(&presolve_parser::parse_file(
772 "src/Templates.tsx",
773 r#"
774@component("x-card") class Card extends Component {
775 @slot() children!: SlotContent;
776 render() { return <article><slot /></article>; }
777}
778@component("x-page") class Page extends Component {
779 render() { return <Card><template data-kind="ordinary"><b>Body</b></template></Card>; }
780}
781"#,
782 ));
783 let invocation = asm.component_invocations().into_iter().next().unwrap();
784 let fragments = asm.slot_content_fragments_for(&invocation.id);
785
786 assert_eq!(fragments.len(), 1);
787 assert_eq!(fragments[0].requested_slot_name, "children");
788 assert_eq!(fragments[0].status, SlotContentFragmentStatus::Resolved);
789 assert!(fragments[0].violations.is_empty());
790 assert!(fragments[0].content_template_entities[0]
791 .as_str()
792 .contains("/element:"));
793 }
794
795 #[test]
796 fn blocks_content_for_an_unresolved_invocation_without_runtime_lookup() {
797 let asm = build_application_semantic_model(&presolve_parser::parse_file(
798 "src/Unresolved.tsx",
799 r#"
800@component("x-page") class Page extends Component {
801 render() { return <Missing>Body</Missing>; }
802}
803"#,
804 ));
805 let invocation = asm.component_invocations().into_iter().next().unwrap();
806 let fragment = asm
807 .slot_content_fragments_for(&invocation.id)
808 .into_iter()
809 .next()
810 .unwrap();
811
812 assert_eq!(fragment.status, SlotContentFragmentStatus::Blocked);
813 assert!(fragment
814 .violations
815 .contains(&SlotContentFragmentViolation::UnresolvedInvocationTarget));
816 assert!(fragment.slot.is_none());
817 }
818}