1use std::collections::{BTreeMap, BTreeSet};
4
5use crate::template_graph::{AttributeValue, ElementNode, FragmentNode, TemplateAttribute};
6use crate::{
7 build_ordinary_template_instance_registry, build_resume_anchor_plan, ApplicationSemanticModel,
8 ComponentInstanceId, ComponentInstanceStatus, ResumeAnchorPlacement, SerializableValue,
9 SlotBindingStatus, TemplateChild, TemplateNode,
10};
11
12#[derive(Debug, Default)]
13struct ResumeHtmlMarkers {
14 element_anchors: BTreeMap<String, String>,
15 text_anchors: BTreeMap<String, String>,
16 structural_starts: BTreeMap<String, String>,
17 structural_ends: BTreeMap<String, String>,
18 events: BTreeMap<String, String>,
19}
20
21#[derive(Debug, Clone)]
22enum SlotProjection<'a> {
23 Authored {
24 outlet_entity: crate::SemanticId,
25 caller_instance: ComponentInstanceId,
26 caller_template: &'a TemplateNode,
27 invocation_element: &'a ElementNode,
28 invocation_path: String,
29 content_entities: BTreeSet<crate::SemanticId>,
30 caller_slot_projections: &'a [SlotProjection<'a>],
31 },
32 DirectLayoutChild {
33 outlet_entity: crate::SemanticId,
34 child_instance: ComponentInstanceId,
35 },
36}
37
38#[must_use]
41pub fn generate_ordinary_instance_html(model: &ApplicationSemanticModel) -> String {
42 generate_ordinary_instance_html_for_roots(model, None)
43}
44
45#[must_use]
49pub fn generate_ordinary_instance_html_for_component(
50 model: &ApplicationSemanticModel,
51 root_component: &crate::SemanticId,
52) -> String {
53 generate_ordinary_instance_html_for_roots(model, Some(root_component))
54}
55
56fn generate_ordinary_instance_html_for_roots(
57 model: &ApplicationSemanticModel,
58 root_component: Option<&crate::SemanticId>,
59) -> String {
60 let registry = build_ordinary_template_instance_registry(model);
61 let resume = build_resume_anchor_plan(model);
62 let mut resume_markers = ResumeHtmlMarkers::default();
63 for anchor in &resume.anchors {
64 let target = anchor.marker_target_id.to_string();
65 let index = match anchor.placement {
66 ResumeAnchorPlacement::ElementAttribute => &mut resume_markers.element_anchors,
67 ResumeAnchorPlacement::TextTemplate => &mut resume_markers.text_anchors,
68 ResumeAnchorPlacement::StructuralStartComment => &mut resume_markers.structural_starts,
69 ResumeAnchorPlacement::StructuralEndComment => &mut resume_markers.structural_ends,
70 };
71 index.insert(target, anchor.anchor_id.to_string());
72 }
73 for event in &resume.events {
74 resume_markers.events.insert(
75 event.target_id.to_string(),
76 event.resume_event_id.to_string(),
77 );
78 }
79 let targets = registry
80 .targets
81 .iter()
82 .map(|record| {
83 (
84 (
85 record.component_instance_id.clone(),
86 record.template_entity_id.clone(),
87 ),
88 record.target_id.to_string(),
89 )
90 })
91 .collect::<BTreeMap<_, _>>();
92 let bindings = registry
93 .bindings
94 .iter()
95 .map(|record| {
96 (
97 (
98 record.component_instance_id.clone(),
99 record.declaration_binding_id.clone(),
100 ),
101 record.instance_binding_id.to_string(),
102 )
103 })
104 .collect::<BTreeMap<_, _>>();
105 let children = model
106 .component_instance_plan
107 .instances
108 .values()
109 .filter_map(|instance| {
110 instance.parent_instance.as_ref().and_then(|parent| {
111 instance
112 .invocation
113 .as_ref()
114 .map(|invocation| ((parent.clone(), invocation.clone()), instance))
115 })
116 })
117 .collect::<BTreeMap<_, _>>();
118 let templates = model
119 .templates
120 .iter()
121 .map(|template| (template.id.clone(), template))
122 .collect::<BTreeMap<_, _>>();
123 model
124 .component_instance_plan
125 .instances
126 .values()
127 .filter(|instance| {
128 instance.parent_instance.is_none()
129 && instance.status == ComponentInstanceStatus::Planned
130 && root_component.is_none_or(|component| instance.component == *component)
131 })
132 .map(|instance| {
133 render_instance(
134 model,
135 &templates,
136 &children,
137 &targets,
138 &bindings,
139 &resume_markers,
140 &instance.id,
141 &instance.component,
142 &[],
143 )
144 })
145 .collect::<Vec<_>>()
146 .join("\n")
147}
148
149#[allow(clippy::too_many_arguments)]
150fn render_instance(
151 model: &ApplicationSemanticModel,
152 templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
153 children: &BTreeMap<
154 (ComponentInstanceId, crate::ComponentInvocationId),
155 &crate::ComponentInstance,
156 >,
157 targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
158 bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
159 resume_markers: &ResumeHtmlMarkers,
160 instance: &ComponentInstanceId,
161 component: &crate::SemanticId,
162 slot_projections: &[SlotProjection<'_>],
163) -> String {
164 let mut instance_slot_projections = slot_projections.to_vec();
165 instance_slot_projections.extend(direct_layout_slot_projections(model, instance));
166 let Some(template) = templates.get(&component.template()) else {
167 return String::new();
168 };
169 if let Some(root) = &template.root {
170 return render_element(
171 model,
172 templates,
173 children,
174 targets,
175 bindings,
176 resume_markers,
177 instance,
178 template,
179 root,
180 "root",
181 &instance_slot_projections,
182 );
183 }
184 template
185 .root_fragment
186 .as_ref()
187 .map_or_else(String::new, |fragment| {
188 render_fragment(
189 model,
190 templates,
191 children,
192 targets,
193 bindings,
194 resume_markers,
195 instance,
196 template,
197 fragment,
198 "root",
199 &instance_slot_projections,
200 )
201 })
202}
203
204#[allow(clippy::too_many_arguments)]
205fn render_fragment(
206 model: &ApplicationSemanticModel,
207 templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
208 children: &BTreeMap<
209 (ComponentInstanceId, crate::ComponentInvocationId),
210 &crate::ComponentInstance,
211 >,
212 targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
213 bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
214 resume_markers: &ResumeHtmlMarkers,
215 instance: &ComponentInstanceId,
216 template: &TemplateNode,
217 fragment: &FragmentNode,
218 path: &str,
219 slot_projections: &[SlotProjection<'_>],
220) -> String {
221 render_children(
222 model,
223 templates,
224 children,
225 targets,
226 bindings,
227 resume_markers,
228 instance,
229 template,
230 &fragment.children,
231 path,
232 slot_projections,
233 )
234}
235
236#[allow(clippy::too_many_arguments)]
237fn render_children(
238 model: &ApplicationSemanticModel,
239 templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
240 children: &BTreeMap<
241 (ComponentInstanceId, crate::ComponentInvocationId),
242 &crate::ComponentInstance,
243 >,
244 targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
245 bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
246 resume_markers: &ResumeHtmlMarkers,
247 instance: &ComponentInstanceId,
248 template: &TemplateNode,
249 nodes: &[TemplateChild],
250 parent_path: &str,
251 slot_projections: &[SlotProjection<'_>],
252) -> String {
253 nodes
254 .iter()
255 .enumerate()
256 .map(|(index, node)| {
257 render_child(
258 model,
259 templates,
260 children,
261 targets,
262 bindings,
263 resume_markers,
264 instance,
265 template,
266 node,
267 &format!("{parent_path}.{index}"),
268 slot_projections,
269 )
270 })
271 .collect()
272}
273
274#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
275fn render_child(
276 model: &ApplicationSemanticModel,
277 templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
278 children: &BTreeMap<
279 (ComponentInstanceId, crate::ComponentInvocationId),
280 &crate::ComponentInstance,
281 >,
282 targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
283 bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
284 resume_markers: &ResumeHtmlMarkers,
285 instance: &ComponentInstanceId,
286 template: &TemplateNode,
287 node: &TemplateChild,
288 path: &str,
289 slot_projections: &[SlotProjection<'_>],
290) -> String {
291 match node {
292 TemplateChild::Text { value, .. } => escape_text(value),
293 TemplateChild::Binding {
294 expression,
295 initial_value,
296 ..
297 } => {
298 let entity = template.id.template_entity("binding", path);
299 let value = initial_value
300 .as_ref()
301 .map_or_else(String::new, SerializableValue::render_text);
302 let marker_target = targets.get(&(instance.clone(), entity.clone()));
303 let resume_marker = marker_target
304 .and_then(|target| resume_markers.text_anchors.get(target))
305 .map_or_else(String::new, |anchor| {
306 format!(
307 "<template data-presolve-r=\"{}\"></template>",
308 escape_attr(anchor)
309 )
310 });
311 bindings.get(&(instance.clone(), entity)).map_or_else(
312 || format!("{resume_marker}<!-- presolve-binding:{expression} -->{value}"),
313 |id| format!("{resume_marker}<!--presolve-ti-binding-start:{id}-->{value}<!--presolve-ti-binding-end:{id}-->"),
314 )
315 }
316 TemplateChild::Element(element) => render_element(
317 model,
318 templates,
319 children,
320 targets,
321 bindings,
322 resume_markers,
323 instance,
324 template,
325 element,
326 path,
327 slot_projections,
328 ),
329 TemplateChild::Fragment(fragment) => render_fragment(
330 model,
331 templates,
332 children,
333 targets,
334 bindings,
335 resume_markers,
336 instance,
337 template,
338 fragment,
339 path,
340 slot_projections,
341 ),
342 TemplateChild::Conditional(conditional) => {
343 let entity = template.id.template_entity("conditional", path);
344 let marker = targets
345 .get(&(instance.clone(), entity))
346 .map_or("", String::as_str);
347 let resume_start = resume_markers
348 .structural_starts
349 .get(marker)
350 .map_or("", String::as_str);
351 let resume_end = resume_markers
352 .structural_ends
353 .get(marker)
354 .map_or("", String::as_str);
355 let selected = if matches!(
356 conditional.initial_value,
357 Some(SerializableValue::Boolean(true))
358 ) {
359 &conditional.when_true
360 } else {
361 &conditional.when_false
362 };
363 format!(
364 "<!--presolve-r-start:{}--><!--presolve-conditional-start:{}:ti:{}-->{}<!--presolve-conditional-end:{}:ti:{}--><!--presolve-r-end:{}-->",
365 escape_comment(resume_start),
366 conditional.start_id.0,
367 marker,
368 render_children(
369 model,
370 templates,
371 children,
372 targets,
373 bindings,
374 resume_markers,
375 instance,
376 template,
377 selected,
378 path,
379 slot_projections,
380 ),
381 conditional.end_id.0,
382 marker,
383 escape_comment(resume_end)
384 )
385 }
386 TemplateChild::List(list) => {
387 let entity = template.id.template_entity("list", path);
388 let marker = targets
389 .get(&(instance.clone(), entity))
390 .map_or("", String::as_str);
391 let resume_start = resume_markers
392 .structural_starts
393 .get(marker)
394 .map_or("", String::as_str);
395 let resume_end = resume_markers
396 .structural_ends
397 .get(marker)
398 .map_or("", String::as_str);
399 format!(
400 "<!--presolve-r-start:{}--><!--presolve-ti-target-start:{marker}-->{}<!--presolve-ti-target-end:{marker}--><!--presolve-r-end:{}-->",
401 escape_comment(resume_start),
402 crate::html_codegen::generate_list_html(list),
403 escape_comment(resume_end),
404 )
405 }
406 }
407}
408
409#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
410fn render_element(
411 model: &ApplicationSemanticModel,
412 templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
413 children: &BTreeMap<
414 (ComponentInstanceId, crate::ComponentInvocationId),
415 &crate::ComponentInstance,
416 >,
417 targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
418 bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
419 resume_markers: &ResumeHtmlMarkers,
420 instance: &ComponentInstanceId,
421 template: &TemplateNode,
422 element: &ElementNode,
423 path: &str,
424 slot_projections: &[SlotProjection<'_>],
425) -> String {
426 let entity = template.id.template_entity("element", path);
427 if element.tag_name == "slot" {
428 if let Some(projection) = slot_projections
429 .iter()
430 .find(|projection| projection.outlet_entity() == &entity)
431 {
432 return render_slot_projection(
433 model,
434 templates,
435 children,
436 targets,
437 bindings,
438 resume_markers,
439 projection,
440 );
441 }
442 if let Some(outlet) = model
443 .slot_outlets
444 .values()
445 .find(|outlet| outlet.template_entity == entity)
446 {
447 let empty = model
448 .slot_bindings
449 .for_callee(instance)
450 .into_iter()
451 .any(|binding| {
452 binding.outlet.as_ref() == Some(&outlet.id)
453 && binding.status == SlotBindingStatus::Empty
454 });
455 if empty {
456 return String::new();
457 }
458 }
459 }
460 if let Some(invocation) = model
461 .component_invocations
462 .values()
463 .find(|candidate| candidate.template_entity == entity)
464 {
465 if let Some(child) = children.get(&(instance.clone(), invocation.id.clone())) {
466 if child.status == ComponentInstanceStatus::Planned {
467 let projections = slot_projections_for_invocation(
468 model,
469 &child.id,
470 instance,
471 template,
472 element,
473 path,
474 slot_projections,
475 );
476 return render_instance(
477 model,
478 templates,
479 children,
480 targets,
481 bindings,
482 resume_markers,
483 &child.id,
484 &child.component,
485 &projections,
486 );
487 }
488 }
489 }
490 let mut html = format!(
491 "<{} data-presolve-node=\"{}\"",
492 element.tag_name,
493 escape_attr(&element.id.0)
494 );
495 if let Some(target) = targets.get(&(instance.clone(), entity)) {
496 html.push_str(" data-presolve-ti=\"");
497 html.push_str(&escape_attr(target));
498 html.push('"');
499 if let Some(anchor) = resume_markers.element_anchors.get(target) {
500 html.push_str(" data-presolve-r=\"");
501 html.push_str(&escape_attr(anchor));
502 html.push('"');
503 }
504 if let Some(event) = resume_markers.events.get(target) {
505 html.push_str(" data-presolve-e=\"");
506 html.push_str(&escape_attr(event));
507 html.push('"');
508 }
509 }
510 for attribute in &element.attributes {
511 html.push(' ');
512 html.push_str(&attribute_html(attribute));
513 }
514 html.push('>');
515 html.push_str(&render_children(
516 model,
517 templates,
518 children,
519 targets,
520 bindings,
521 resume_markers,
522 instance,
523 template,
524 &element.children,
525 path,
526 slot_projections,
527 ));
528 html.push_str("</");
529 html.push_str(&element.tag_name);
530 html.push('>');
531 html
532}
533
534#[allow(clippy::similar_names, clippy::too_many_arguments)]
535fn slot_projections_for_invocation<'a>(
536 model: &'a ApplicationSemanticModel,
537 callee_instance: &ComponentInstanceId,
538 caller_instance: &ComponentInstanceId,
539 caller_template: &'a TemplateNode,
540 invocation_element: &'a ElementNode,
541 invocation_path: &str,
542 caller_slot_projections: &'a [SlotProjection<'a>],
543) -> Vec<SlotProjection<'a>> {
544 model
545 .slot_bindings
546 .for_callee(callee_instance)
547 .into_iter()
548 .filter(|binding| binding.status == SlotBindingStatus::Bound)
549 .filter_map(|binding| {
550 let outlet = model.slot_outlets.get(binding.outlet.as_ref()?)?;
551 let fragment = model
552 .slot_content_fragments
553 .get(binding.content_fragment.as_ref()?)?;
554 Some(SlotProjection::Authored {
555 outlet_entity: outlet.template_entity.clone(),
556 caller_instance: caller_instance.clone(),
557 caller_template,
558 invocation_element,
559 invocation_path: invocation_path.to_string(),
560 content_entities: fragment.content_template_entities.iter().cloned().collect(),
561 caller_slot_projections,
562 })
563 })
564 .collect()
565}
566
567impl SlotProjection<'_> {
568 fn outlet_entity(&self) -> &crate::SemanticId {
569 match self {
570 Self::Authored { outlet_entity, .. }
571 | Self::DirectLayoutChild { outlet_entity, .. } => outlet_entity,
572 }
573 }
574}
575
576fn direct_layout_slot_projections(
577 model: &ApplicationSemanticModel,
578 instance: &ComponentInstanceId,
579) -> Vec<SlotProjection<'static>> {
580 model
581 .slot_bindings
582 .for_callee(instance)
583 .into_iter()
584 .filter(|binding| binding.status == SlotBindingStatus::Bound)
585 .filter_map(|binding| {
586 let child_instance = binding.direct_child_instance.clone()?;
587 let outlet = model.slot_outlets.get(binding.outlet.as_ref()?)?;
588 Some(SlotProjection::DirectLayoutChild {
589 outlet_entity: outlet.template_entity.clone(),
590 child_instance,
591 })
592 })
593 .collect()
594}
595
596#[allow(clippy::too_many_arguments)]
597fn render_slot_projection(
598 model: &ApplicationSemanticModel,
599 templates: &BTreeMap<crate::SemanticId, &TemplateNode>,
600 children: &BTreeMap<
601 (ComponentInstanceId, crate::ComponentInvocationId),
602 &crate::ComponentInstance,
603 >,
604 targets: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
605 bindings: &BTreeMap<(ComponentInstanceId, crate::SemanticId), String>,
606 resume_markers: &ResumeHtmlMarkers,
607 projection: &SlotProjection<'_>,
608) -> String {
609 if let SlotProjection::DirectLayoutChild { child_instance, .. } = projection {
610 let Some(child) = model.component_instance_plan.instances.get(child_instance) else {
611 return String::new();
612 };
613 return render_instance(
614 model,
615 templates,
616 children,
617 targets,
618 bindings,
619 resume_markers,
620 &child.id,
621 &child.component,
622 &[],
623 );
624 }
625 let SlotProjection::Authored {
626 caller_instance,
627 caller_template,
628 invocation_element,
629 invocation_path,
630 content_entities,
631 caller_slot_projections,
632 ..
633 } = projection
634 else {
635 unreachable!("direct layout Slot projection returns above");
636 };
637 let mut html = String::new();
638 for (index, child) in invocation_element.children.iter().enumerate() {
639 let path = format!("{invocation_path}.{index}");
640 let entity = template_child_entity(&caller_template.id, child, &path);
641 if content_entities.contains(&entity) {
642 html.push_str(&render_child(
643 model,
644 templates,
645 children,
646 targets,
647 bindings,
648 resume_markers,
649 caller_instance,
650 caller_template,
651 child,
652 &path,
653 caller_slot_projections,
654 ));
655 continue;
656 }
657 let TemplateChild::Element(wrapper) = child else {
658 continue;
659 };
660 if wrapper.tag_name != "template" {
661 continue;
662 }
663 for (child_index, wrapped) in wrapper.children.iter().enumerate() {
664 let wrapped_path = format!("{path}.{child_index}");
665 let wrapped_entity = template_child_entity(&caller_template.id, wrapped, &wrapped_path);
666 if content_entities.contains(&wrapped_entity) {
667 html.push_str(&render_child(
668 model,
669 templates,
670 children,
671 targets,
672 bindings,
673 resume_markers,
674 caller_instance,
675 caller_template,
676 wrapped,
677 &wrapped_path,
678 caller_slot_projections,
679 ));
680 }
681 }
682 }
683 html
684}
685
686fn template_child_entity(
687 template: &crate::SemanticId,
688 child: &TemplateChild,
689 path: &str,
690) -> crate::SemanticId {
691 template.template_entity(
692 match child {
693 TemplateChild::Text { .. } => "text",
694 TemplateChild::Binding { .. } => "binding",
695 TemplateChild::Element(_) => "element",
696 TemplateChild::Fragment(_) => "fragment",
697 TemplateChild::Conditional(_) => "conditional",
698 TemplateChild::List(_) => "list",
699 },
700 path,
701 )
702}
703
704fn attribute_html(attribute: &TemplateAttribute) -> String {
705 let value = match &attribute.value {
706 AttributeValue::Boolean => return attribute.name.clone(),
707 AttributeValue::Static(value) => value.clone(),
708 AttributeValue::Binding { initial_value, .. } => initial_value
709 .as_ref()
710 .map_or_else(String::new, SerializableValue::render_text),
711 AttributeValue::EventHandler { handler, .. } => handler.clone(),
712 AttributeValue::BindingList(values) => values.join(","),
713 };
714 format!("{}=\"{}\"", attribute.name, escape_attr(&value))
715}
716
717fn escape_attr(value: &str) -> String {
718 value
719 .replace('&', "&")
720 .replace('"', """)
721 .replace('<', "<")
722 .replace('>', ">")
723}
724
725fn escape_comment(value: &str) -> String {
726 value.replace("--", "--")
727}
728fn escape_text(value: &str) -> String {
729 value
730 .replace('&', "&")
731 .replace('<', "<")
732 .replace('>', ">")
733}
734
735#[cfg(test)]
736mod tests {
737 use super::{generate_ordinary_instance_html, generate_ordinary_instance_html_for_component};
738 use crate::{
739 build_application_semantic_model, build_resume_anchor_plan, validate_resume_marker_html,
740 };
741
742 #[test]
743 fn emits_precomputed_repeated_instance_and_exact_resume_markers() {
744 let model = build_application_semantic_model(&presolve_parser::parse_file(
745 "src/Markers.tsx",
746 r#"
747@component("x-child") class Child {
748 count = state(0);
749 @action() increment() { this.count++; }
750 render() { return <button onClick={() => this.increment()}>{this.count}</button>; }
751}
752@component("x-parent") class Parent { render() { return <><Child /><Child /></>; } }
753"#,
754 ));
755 let html = generate_ordinary_instance_html(&model);
756 assert_eq!(html.matches("data-presolve-ti=").count(), 2);
757 assert_eq!(html.matches("presolve-ti-binding-start:").count(), 2);
758 assert_eq!(html.matches("data-presolve-r=").count(), 4);
759 assert_eq!(html.matches("data-presolve-e=").count(), 2);
760 assert_eq!(html, generate_ordinary_instance_html(&model));
761 }
762
763 #[test]
764 fn places_caller_owned_slot_content_at_the_exact_compiler_outlet() {
765 let model = build_application_semantic_model(&presolve_parser::parse_file(
766 "src/Slots.tsx",
767 r#"
768@component("x-leaf") class Leaf {
769 count = state(0);
770 @action() increment() { this.count++; }
771 render() { return <button onClick={() => this.increment()}>{this.count}</button>; }
772}
773@component("x-card") class Card {
774 @slot() children!: SlotContent;
775 render() { return <article><slot /><Leaf /></article>; }
776}
777@component("x-page") class Page { render() { return <main><Card><Leaf /></Card></main>; } }
778"#,
779 ));
780 let html = generate_ordinary_instance_html(&model);
781 let resume = build_resume_anchor_plan(&model);
782
783 assert_eq!(html.matches("<button").count(), 2);
784 assert!(!html.contains("<slot"));
785 assert_eq!(html.matches("presolve-ti-binding-start:").count(), 2);
786 assert!(
787 validate_resume_marker_html(&resume, &html).is_empty(),
788 "caller-owned Slot content must carry every required resume marker"
789 );
790 }
791
792 #[test]
793 fn renders_only_the_selected_entry_component_tree() {
794 let model = build_application_semantic_model(&presolve_parser::parse_file(
795 "src/Entries.tsx",
796 r#"
797@component("x-home") class Home { render() { return <main>Home</main>; } }
798@component("x-about") class About { render() { return <main>About</main>; } }
799"#,
800 ));
801 let home = model
802 .components
803 .iter()
804 .find(|component| component.class_name == "Home")
805 .unwrap();
806
807 let html = generate_ordinary_instance_html_for_component(&model, &home.id);
808
809 assert!(html.contains("Home"));
810 assert!(!html.contains("About"));
811 }
812}