From bc6bd5ce6626447b00a0214da65521b475701f4c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 11 Apr 2025 08:53:04 -0400
Subject: [PATCH 01/17] Define exposed slots schema

---
 config/schema/experience_builder.schema.yml   | 56 +++++++++++++++++++
 src/Entity/ContentTemplate.php                |  3 +
 .../Config/ContentTemplateValidationTest.php  | 11 ++++
 3 files changed, 70 insertions(+)

diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml
index f25ec15bf1..641158f2a3 100644
--- a/config/schema/experience_builder.schema.yml
+++ b/config/schema/experience_builder.schema.yml
@@ -547,3 +547,59 @@ experience_builder.content_template.*.*.*:
               - Drupal\Core\Block\TitleBlockPluginInterface
               - Drupal\Core\Block\MessagesBlockPluginInterface
             presence: ~
+
+# Exposed slots are only supported by the "canonical" view mode, which is generally
+# `full`. Example:
+#
+# exposed_slots:
+#  profile_bio:
+#    label: 'Profile Bio!'
+#    component_uuid: 28bcab26-e434-4ad4-9eaf-0520bdb32fcc
+#    slot_name: column_two
+#  intro:
+#    label: 'Intro'
+#    component_uuid: 98bcab26-e434-4ad4-9eaf-0520bdb32fcc
+#    slot_name: body
+#
+# @see \Drupal\Core\Entity\Controller\EntityViewController::view()
+#
+# @todo Core entity types ship with a `full` view mode, but it's not guaranteed to
+#   exist for every content entity type. Ensure XB creates it when opting a content
+#   entity bundle into XB rendering in https://www.drupal.org/i/3518248.
+experience_builder.content_template.*.*.full:
+  type: experience_builder.content_template.*.*.*
+  constraints:
+    FullyValidatable: ~
+  mapping:
+    exposed_slots:
+      type: sequence
+      # This is ordered by key because the keys are the machine names assigned to the
+      # exposed slots. These machine names are, essentially, "aliases" for a particular
+      # slot in a particular component in the component tree.
+      orderby: key
+      # If this sequence is empty, the site builder that crafted this template has not
+      # exposed any slots to content creators, which means there's nothing for content
+      # creators to do other than enter values for the content entity form (under the
+      # "Page Data" tab in the designs).
+      sequence:
+        type: mapping
+        mapping:
+          # The UUID of the component that contains the slot being exposed. This CANNOT
+          # be the same as \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure::ROOT_UUID,
+          # because that would mean the entire template could be overridden by an individual
+          # content entity, which is not an appropriate use of a content template (we have
+          # a whole distinct landing page content entity type for that).
+          # @todo Validate that:
+          # - This is not the root UUID
+          # - This component actually exists in the component tree
+          # - The slot we're exposing is empty.
+          component_uuid:
+            type: uuid
+            label: 'UUID of the component instance that contains the exposed slot'
+          # @todo Validate that the slot name is actually defined by the component.
+          slot_name:
+            type: string
+            label: 'The name of the slot exposed slot, as known to the component'
+          label:
+            type: required_label
+            label: 'A human-readable label for the exposed slot'
diff --git a/src/Entity/ContentTemplate.php b/src/Entity/ContentTemplate.php
index 2672b4beb7..410f5a399e 100644
--- a/src/Entity/ContentTemplate.php
+++ b/src/Entity/ContentTemplate.php
@@ -53,6 +53,7 @@ use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItemInstantiat
     'content_entity_type_bundle',
     'content_entity_type_view_mode',
     'component_tree',
+    'exposed_slots',
   ],
 )]
 final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEntityInterface, EntityViewDisplayInterface {
@@ -98,6 +99,8 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt
    */
   protected ?array $component_tree;
 
+  protected array $exposed_slots = [];
+
   /**
    * Tries to load a template for a particular entity, in a specific view mode.
    *
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 48736f4f66..134665fe25 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -357,4 +357,15 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
     ]);
   }
 
+  public function testRootUuidCannotBeExposed(): void {
+    $this->entity->set('exposed_slots', [
+      'not_allowed' => [
+        'component_uuid' => ComponentTreeStructure::ROOT_UUID,
+        'slot_name' => 'not-a-thing',
+        'label' => "This won't work",
+      ],
+    ]);
+    $this->assertValidationErrors([]);
+  }
+
 }
-- 
GitLab


From c56dcda481667e17dffb0b8ea9f4f79d5e770af9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 11 Apr 2025 09:31:31 -0400
Subject: [PATCH 02/17] Set up the validation test cases

---
 config/schema/experience_builder.schema.yml   |  5 --
 .../Config/ContentTemplateValidationTest.php  | 79 ++++++++++++++++++-
 2 files changed, 78 insertions(+), 6 deletions(-)

diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml
index 641158f2a3..b169d74e90 100644
--- a/config/schema/experience_builder.schema.yml
+++ b/config/schema/experience_builder.schema.yml
@@ -589,14 +589,9 @@ experience_builder.content_template.*.*.full:
           # because that would mean the entire template could be overridden by an individual
           # content entity, which is not an appropriate use of a content template (we have
           # a whole distinct landing page content entity type for that).
-          # @todo Validate that:
-          # - This is not the root UUID
-          # - This component actually exists in the component tree
-          # - The slot we're exposing is empty.
           component_uuid:
             type: uuid
             label: 'UUID of the component instance that contains the exposed slot'
-          # @todo Validate that the slot name is actually defined by the component.
           slot_name:
             type: string
             label: 'The name of the slot exposed slot, as known to the component'
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 134665fe25..931dd48060 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -365,7 +365,84 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
         'label' => "This won't work",
       ],
     ]);
-    $this->assertValidationErrors([]);
+    $this->fail('Validate that the root UUID is not exposed.');
+  }
+
+  public function testExposedSlotComponentMustExistInTree(): void {
+    $this->entity->set('exposed_slots', [
+      'not_a_thing' => [
+        'component_uuid' => 'not-in-the-tree',
+        'slot_name' => 'not-a-thing',
+        'label' => "Can't expose a slot in a component we don't have!",
+      ],
+    ]);
+    $this->fail('Validate that the component with the exposed slot exists in the tree.');
+  }
+
+  public function testExposedSlotMustBeEmpty(): void {
+    $this->entity->set('component_tree', [
+      'tree' => self::encodeXBData([
+        ComponentTreeStructure::ROOT_UUID => [
+          ['uuid' => 'has-exposed-slot', 'component' => 'sdc.xb_test_sdc.props-slots'],
+        ],
+        'has-exposed-slot' => [
+          'the_footer' => [
+            ['uuid' => 'greeting', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
+          ],
+        ],
+      ]),
+      'inputs' => self::encodeXBData([
+        'has-exposed-slot' => [
+          'heading' => [
+            'sourceType' => 'static:field_item:string',
+            'value' => "My footer is showing",
+            'expression' => 'ℹ︎string␟value',
+          ],
+        ],
+        'greeting' => [
+          'heading' => [
+            'sourceType' => 'static:field_item:string',
+            'value' => "I shouldn't be here",
+            'expression' => 'ℹ︎string␟value',
+          ],
+        ],
+      ]),
+    ]);
+    $this->entity->set('exposed_slots', [
+      'filled_footer' => [
+        'component_uuid' => 'has-exposed-slot',
+        'slot_name' => 'the_footer',
+        'label' => "Something's already here!",
+      ],
+    ]);
+    $this->fail('Validate that the exposed slot must be empty.');
+  }
+
+  public function testExposedSlotMustBeDefinedByComponent(): void {
+    $this->entity->set('component_tree', [
+      'tree' => self::encodeXBData([
+        ComponentTreeStructure::ROOT_UUID => [
+          ['uuid' => 'has-exposed-slot', 'component' => 'sdc.xb_test_sdc.props-slots'],
+        ],
+      ]),
+      'inputs' => self::encodeXBData([
+        'has-exposed-slot' => [
+          'heading' => [
+            'sourceType' => 'static:field_item:string',
+            'value' => "My footer is showing",
+            'expression' => 'ℹ︎string␟value',
+          ],
+        ],
+      ]),
+    ]);
+    $this->entity->set('exposed_slots', [
+      'filled_footer' => [
+        'component_uuid' => 'has-exposed-slot',
+        'slot_name' => 'not_a_real_slot',
+        'label' => "Whither this slot you speak of?",
+      ],
+    ]);
+    $this->fail('Validate that the exposed slot is defined by the component.');
   }
 
 }
-- 
GitLab


From a190e60029720a2247a5f42a645754fa01886e30 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 11 Apr 2025 09:58:56 -0400
Subject: [PATCH 03/17] Start validating

---
 config/schema/experience_builder.schema.yml   | 50 ++++++++++---------
 .../Constraint/ValidExposedSlotConstraint.php | 21 ++++++++
 .../ValidExposedSlotConstraintValidator.php   | 40 +++++++++++++++
 .../Config/ContentTemplateValidationTest.php  | 10 ++--
 4 files changed, 94 insertions(+), 27 deletions(-)
 create mode 100644 src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
 create mode 100644 src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php

diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml
index b169d74e90..dbdf29efba 100644
--- a/config/schema/experience_builder.schema.yml
+++ b/config/schema/experience_builder.schema.yml
@@ -547,30 +547,6 @@ experience_builder.content_template.*.*.*:
               - Drupal\Core\Block\TitleBlockPluginInterface
               - Drupal\Core\Block\MessagesBlockPluginInterface
             presence: ~
-
-# Exposed slots are only supported by the "canonical" view mode, which is generally
-# `full`. Example:
-#
-# exposed_slots:
-#  profile_bio:
-#    label: 'Profile Bio!'
-#    component_uuid: 28bcab26-e434-4ad4-9eaf-0520bdb32fcc
-#    slot_name: column_two
-#  intro:
-#    label: 'Intro'
-#    component_uuid: 98bcab26-e434-4ad4-9eaf-0520bdb32fcc
-#    slot_name: body
-#
-# @see \Drupal\Core\Entity\Controller\EntityViewController::view()
-#
-# @todo Core entity types ship with a `full` view mode, but it's not guaranteed to
-#   exist for every content entity type. Ensure XB creates it when opting a content
-#   entity bundle into XB rendering in https://www.drupal.org/i/3518248.
-experience_builder.content_template.*.*.full:
-  type: experience_builder.content_template.*.*.*
-  constraints:
-    FullyValidatable: ~
-  mapping:
     exposed_slots:
       type: sequence
       # This is ordered by key because the keys are the machine names assigned to the
@@ -583,6 +559,8 @@ experience_builder.content_template.*.*.full:
       # "Page Data" tab in the designs).
       sequence:
         type: mapping
+        constraints:
+          ValidExposedSlot: ~
         mapping:
           # The UUID of the component that contains the slot being exposed. This CANNOT
           # be the same as \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure::ROOT_UUID,
@@ -598,3 +576,27 @@ experience_builder.content_template.*.*.full:
           label:
             type: required_label
             label: 'A human-readable label for the exposed slot'
+
+# Exposed slots are only supported by the "canonical" view mode, which is generally
+# `full`. Example:
+#
+# exposed_slots:
+#  profile_bio:
+#    label: 'Profile Bio!'
+#    component_uuid: 28bcab26-e434-4ad4-9eaf-0520bdb32fcc
+#    slot_name: column_two
+#  intro:
+#    label: 'Intro'
+#    component_uuid: 98bcab26-e434-4ad4-9eaf-0520bdb32fcc
+#    slot_name: body
+#
+# @see \Drupal\Core\Entity\Controller\EntityViewController::view()
+#
+# @todo Core entity types ship with a `full` view mode, but it's not guaranteed to
+#   exist for every content entity type. Ensure XB creates it when opting a content
+#   entity bundle into XB rendering in https://www.drupal.org/i/3518248.
+#experience_builder.content_template.*.*.full:
+#  type: experience_builder.content_template.*.*.*
+#  constraints:
+#    FullyValidatable: ~
+#  mapping:
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
new file mode 100644
index 0000000000..04b8736b21
--- /dev/null
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\experience_builder\Plugin\Validation\Constraint;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\Validation\Attribute\Constraint;
+use Symfony\Component\Validator\Constraint as SymfonyConstraint;
+
+#[Constraint(
+  id: 'ValidExposedSlot',
+  label: new TranslatableMarkup('Validates an exposed slot', [], ['context' => 'Validation']),
+)]
+final class ValidExposedSlotConstraint extends SymfonyConstraint {
+
+  public string $rootExposedMessage = 'Exposing the full component tree is not allowed.';
+
+  public string $unknownComponentMessage = 'The component %id does not exist in the tree.';
+
+}
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
new file mode 100644
index 0000000000..cb6c49f962
--- /dev/null
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\experience_builder\Plugin\Validation\Constraint;
+
+use Drupal\experience_builder\Entity\ComponentTreeEntityInterface;
+use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+
+/**
+ * Validates the `ValidExposedSlot` constraint.
+ */
+final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate(mixed $value, Constraint $constraint): void {
+    assert($constraint instanceof ValidExposedSlotConstraint);
+
+    assert(is_array($value));
+    if ($value['component_uuid'] === ComponentTreeStructure::ROOT_UUID) {
+      $this->context->addViolation($constraint->rootExposedMessage);
+    }
+
+    $root = $this->context->getRoot()->getEntity();
+    assert($root instanceof ComponentTreeEntityInterface);
+    /** @var \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure $tree */
+    $tree = $root->getComponentTree()->get('tree');
+
+    if (!in_array($value['component_uuid'], $tree->getComponentInstanceUuids(), TRUE)) {
+      $this->context->addViolation($constraint->unknownComponentMessage, [
+        '%id' => $value['component_uuid'],
+      ]);
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 931dd48060..c7d7325d40 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -365,18 +365,22 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
         'label' => "This won't work",
       ],
     ]);
-    $this->fail('Validate that the root UUID is not exposed.');
+    $this->assertValidationErrors([
+      'exposed_slots.not_allowed' => 'Exposing the full component tree is not allowed.',
+    ]);
   }
 
   public function testExposedSlotComponentMustExistInTree(): void {
     $this->entity->set('exposed_slots', [
       'not_a_thing' => [
-        'component_uuid' => 'not-in-the-tree',
+        'component_uuid' => 'f0a5a653-ee1c-4469-903a-119d70edbf02',
         'slot_name' => 'not-a-thing',
         'label' => "Can't expose a slot in a component we don't have!",
       ],
     ]);
-    $this->fail('Validate that the component with the exposed slot exists in the tree.');
+    $this->assertValidationErrors([
+      'exposed_slots.not_a_thing' => 'The component <em class="placeholder">f0a5a653-ee1c-4469-903a-119d70edbf02</em> does not exist in the tree.',
+    ]);
   }
 
   public function testExposedSlotMustBeEmpty(): void {
-- 
GitLab


From 6d4b55fcd4e42f301848d099600086ba2423ff24 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 11 Apr 2025 10:35:08 -0400
Subject: [PATCH 04/17] Ensure slot actually exists

---
 .../Constraint/ValidExposedSlotConstraint.php     |  2 ++
 .../ValidExposedSlotConstraintValidator.php       | 13 +++++++++++++
 .../Config/ContentTemplateValidationTest.php      | 15 ++++++++-------
 3 files changed, 23 insertions(+), 7 deletions(-)

diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
index 04b8736b21..6e422d881f 100644
--- a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
@@ -18,4 +18,6 @@ final class ValidExposedSlotConstraint extends SymfonyConstraint {
 
   public string $unknownComponentMessage = 'The component %id does not exist in the tree.';
 
+  public string $undefinedSlotMessage = 'The component %id does not have a %slot slot.';
+
 }
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
index cb6c49f962..2a3d42d10d 100644
--- a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\experience_builder\Plugin\Validation\Constraint;
 
+use Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface;
 use Drupal\experience_builder\Entity\ComponentTreeEntityInterface;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Symfony\Component\Validator\Constraint;
@@ -35,6 +36,18 @@ final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
         '%id' => $value['component_uuid'],
       ]);
     }
+
+    $source = $tree->getComponentSource($value['component_uuid']);
+    $slot_exists = FALSE;
+    if ($source instanceof ComponentSourceWithSlotsInterface) {
+      $slot_exists = array_key_exists($value['slot_name'], $source->getSlotDefinitions());
+    }
+    if ($slot_exists === FALSE) {
+      $this->context->addViolation($constraint->undefinedSlotMessage, [
+        '%id' => $value['component_uuid'],
+        '%slot' => $value['slot_name'],
+      ]);
+    }
   }
 
 }
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index c7d7325d40..3d26c6bd0e 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -426,27 +426,28 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
     $this->entity->set('component_tree', [
       'tree' => self::encodeXBData([
         ComponentTreeStructure::ROOT_UUID => [
-          ['uuid' => 'has-exposed-slot', 'component' => 'sdc.xb_test_sdc.props-slots'],
+          ['uuid' => '20398504-ce6f-4a79-819e-405ea34eefc4', 'component' => 'sdc.xb_test_sdc.props-slots'],
         ],
       ]),
       'inputs' => self::encodeXBData([
-        'has-exposed-slot' => [
+        '20398504-ce6f-4a79-819e-405ea34eefc4' => [
           'heading' => [
-            'sourceType' => 'static:field_item:string',
-            'value' => "My footer is showing",
-            'expression' => 'ℹ︎string␟value',
+            'sourceType' => 'dynamic',
+            'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
           ],
         ],
       ]),
     ]);
     $this->entity->set('exposed_slots', [
       'filled_footer' => [
-        'component_uuid' => 'has-exposed-slot',
+        'component_uuid' => '20398504-ce6f-4a79-819e-405ea34eefc4',
         'slot_name' => 'not_a_real_slot',
         'label' => "Whither this slot you speak of?",
       ],
     ]);
-    $this->fail('Validate that the exposed slot is defined by the component.');
+    $this->assertValidationErrors([
+      'exposed_slots.filled_footer' => 'The component <em class="placeholder">20398504-ce6f-4a79-819e-405ea34eefc4</em> does not have a <em class="placeholder">not_a_real_slot</em> slot.',
+    ]);
   }
 
 }
-- 
GitLab


From c69688940ebbed5ef75b8e2b53322f839f1ef45c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 11 Apr 2025 11:02:28 -0400
Subject: [PATCH 05/17] Finish validation of the exposed slot

---
 .../ComponentSourceInterface.php               |  4 ++++
 .../Constraint/ValidExposedSlotConstraint.php  |  2 ++
 .../ValidExposedSlotConstraintValidator.php    | 18 +++++++++++++++++-
 .../Config/ContentTemplateValidationTest.php   | 17 +++++++++--------
 4 files changed, 32 insertions(+), 9 deletions(-)

diff --git a/src/ComponentSource/ComponentSourceInterface.php b/src/ComponentSource/ComponentSourceInterface.php
index 15b9a0982e..b2fb824699 100644
--- a/src/ComponentSource/ComponentSourceInterface.php
+++ b/src/ComponentSource/ComponentSourceInterface.php
@@ -31,11 +31,15 @@ use Symfony\Component\Validator\ConstraintViolationListInterface;
  * - a block plugin component source — which renders the a block and needs
  *   settings for the block plugin
  *
+ * Not all component sources support slots. A source that supports slots should
+ * implement \Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface.
+ *
  * @phpstan-import-type PropSourceArray from \Drupal\experience_builder\PropSource\PropSourceBase
  *
  * @see \Drupal\experience_builder\Attribute\ComponentSource
  * @see \Drupal\experience_builder\ComponentSource\ComponentSourceBase
  * @see \Drupal\experience_builder\ComponentSource\ComponentSourceManager
+ * @see \Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface
  */
 interface ComponentSourceInterface extends PluginInspectionInterface, DerivativeInspectionInterface, ConfigurableInterface, PluginFormInterface, DependentPluginInterface, ContextAwarePluginInterface {
 
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
index 6e422d881f..a706ac247f 100644
--- a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
@@ -18,6 +18,8 @@ final class ValidExposedSlotConstraint extends SymfonyConstraint {
 
   public string $unknownComponentMessage = 'The component %id does not exist in the tree.';
 
+  public string $slotNotEmptyMessage = 'The %slot slot must be empty.';
+
   public string $undefinedSlotMessage = 'The component %id does not have a %slot slot.';
 
 }
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
index 2a3d42d10d..c3cdb440e6 100644
--- a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
@@ -9,6 +9,7 @@ use Drupal\experience_builder\Entity\ComponentTreeEntityInterface;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Symfony\Component\Validator\Constraint;
 use Symfony\Component\Validator\ConstraintValidator;
+use Symfony\Component\Validator\Exception\UnexpectedTypeException;
 
 /**
  * Validates the `ValidExposedSlot` constraint.
@@ -21,7 +22,9 @@ final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
   public function validate(mixed $value, Constraint $constraint): void {
     assert($constraint instanceof ValidExposedSlotConstraint);
 
-    assert(is_array($value));
+    assert(is_array($value), new UnexpectedTypeException($value, 'array'));
+
+    // The root UUID (i.e., the entire component tree) cannot be exposed.
     if ($value['component_uuid'] === ComponentTreeStructure::ROOT_UUID) {
       $this->context->addViolation($constraint->rootExposedMessage);
     }
@@ -31,12 +34,15 @@ final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
     /** @var \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure $tree */
     $tree = $root->getComponentTree()->get('tree');
 
+    // The component that contains the exposed slot actually has to, y'know, be
+    // in the tree somewhere.
     if (!in_array($value['component_uuid'], $tree->getComponentInstanceUuids(), TRUE)) {
       $this->context->addViolation($constraint->unknownComponentMessage, [
         '%id' => $value['component_uuid'],
       ]);
     }
 
+    // The component has to actually define the slot being exposed.
     $source = $tree->getComponentSource($value['component_uuid']);
     $slot_exists = FALSE;
     if ($source instanceof ComponentSourceWithSlotsInterface) {
@@ -48,6 +54,16 @@ final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
         '%slot' => $value['slot_name'],
       ]);
     }
+
+    // The exposed slot has to be empty.
+    foreach ($tree->getSlotChildrenDepthFirst() as $parent_uuid => $child) {
+      if ($parent_uuid === $value['component_uuid']) {
+        $this->context->addViolation($constraint->slotNotEmptyMessage, [
+          '%slot' => $child['slot'],
+        ]);
+        break;
+      }
+    }
   }
 
 }
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 3d26c6bd0e..4c4af7e894 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -387,16 +387,16 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
     $this->entity->set('component_tree', [
       'tree' => self::encodeXBData([
         ComponentTreeStructure::ROOT_UUID => [
-          ['uuid' => 'has-exposed-slot', 'component' => 'sdc.xb_test_sdc.props-slots'],
+          ['uuid' => '67062b9a-b35f-453f-a66b-fdb9988b604b', 'component' => 'sdc.xb_test_sdc.props-slots'],
         ],
-        'has-exposed-slot' => [
+        '67062b9a-b35f-453f-a66b-fdb9988b604b' => [
           'the_footer' => [
             ['uuid' => 'greeting', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
           ],
         ],
       ]),
       'inputs' => self::encodeXBData([
-        'has-exposed-slot' => [
+        '67062b9a-b35f-453f-a66b-fdb9988b604b' => [
           'heading' => [
             'sourceType' => 'static:field_item:string',
             'value' => "My footer is showing",
@@ -405,21 +405,22 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
         ],
         'greeting' => [
           'heading' => [
-            'sourceType' => 'static:field_item:string',
-            'value' => "I shouldn't be here",
-            'expression' => 'ℹ︎string␟value',
+            'sourceType' => 'dynamic',
+            'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
           ],
         ],
       ]),
     ]);
     $this->entity->set('exposed_slots', [
       'filled_footer' => [
-        'component_uuid' => 'has-exposed-slot',
+        'component_uuid' => '67062b9a-b35f-453f-a66b-fdb9988b604b',
         'slot_name' => 'the_footer',
         'label' => "Something's already here!",
       ],
     ]);
-    $this->fail('Validate that the exposed slot must be empty.');
+    $this->assertValidationErrors([
+      'exposed_slots.filled_footer' => 'The <em class="placeholder">the_footer</em> slot must be empty.',
+    ]);
   }
 
   public function testExposedSlotMustBeDefinedByComponent(): void {
-- 
GitLab


From 0e064ae3e21daa8fa2131f37d5aa7a5f1e9e8159 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 11 Apr 2025 12:17:39 -0400
Subject: [PATCH 06/17] Fix ContentTemplateValidationTest

---
 src/Entity/ContentTemplate.php                |  7 ++++++-
 .../ValidExposedSlotConstraintValidator.php   | 19 +++++++++++--------
 .../Config/ContentTemplateValidationTest.php  |  5 ++++-
 3 files changed, 21 insertions(+), 10 deletions(-)

diff --git a/src/Entity/ContentTemplate.php b/src/Entity/ContentTemplate.php
index 410f5a399e..b3fcdd0fde 100644
--- a/src/Entity/ContentTemplate.php
+++ b/src/Entity/ContentTemplate.php
@@ -99,7 +99,12 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt
    */
   protected ?array $component_tree;
 
-  protected array $exposed_slots = [];
+  /**
+   * The exposed slots.
+   *
+   * @var ?array<string, array{'component_uuid': string, 'slot_name': string, 'label': string}>
+   */
+  protected ?array $exposed_slots = [];
 
   /**
    * Tries to load a template for a particular entity, in a specific view mode.
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
index c3cdb440e6..c6baa3409d 100644
--- a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
@@ -34,20 +34,23 @@ final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
     /** @var \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure $tree */
     $tree = $root->getComponentTree()->get('tree');
 
-    // The component that contains the exposed slot actually has to, y'know, be
-    // in the tree somewhere.
-    if (!in_array($value['component_uuid'], $tree->getComponentInstanceUuids(), TRUE)) {
+    $slot_exists = FALSE;
+    try {
+      $source = $tree->getComponentSource($value['component_uuid']);
+      if ($source instanceof ComponentSourceWithSlotsInterface) {
+        $slot_exists = array_key_exists($value['slot_name'], $source->getSlotDefinitions());
+      }
+    }
+    catch (\OutOfRangeException) {
+      // The component that contains the exposed slot isn't in the tree at all,
+      // so there's nothing else for us to do.
       $this->context->addViolation($constraint->unknownComponentMessage, [
         '%id' => $value['component_uuid'],
       ]);
+      return;
     }
 
     // The component has to actually define the slot being exposed.
-    $source = $tree->getComponentSource($value['component_uuid']);
-    $slot_exists = FALSE;
-    if ($source instanceof ComponentSourceWithSlotsInterface) {
-      $slot_exists = array_key_exists($value['slot_name'], $source->getSlotDefinitions());
-    }
     if ($slot_exists === FALSE) {
       $this->context->addViolation($constraint->undefinedSlotMessage, [
         '%id' => $value['component_uuid'],
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 4c4af7e894..01d65b9266 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -366,7 +366,10 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
       ],
     ]);
     $this->assertValidationErrors([
-      'exposed_slots.not_allowed' => 'Exposing the full component tree is not allowed.',
+      'exposed_slots.not_allowed' => [
+        'Exposing the full component tree is not allowed.',
+        'The component <em class="placeholder">a548b48d-58a8-4077-aa04-da9405a6f418</em> does not exist in the tree.',
+      ],
     ]);
   }
 
-- 
GitLab


From 96941258f8f2e04301dabc31dec07f5d2cf1134c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 15 Apr 2025 14:30:13 -0400
Subject: [PATCH 07/17] Minor feedback additions

---
 src/Entity/ContentTemplate.php                               | 2 +-
 .../Validation/Constraint/ValidExposedSlotConstraint.php     | 4 +++-
 .../Constraint/ValidExposedSlotConstraintValidator.php       | 1 +
 tests/src/Kernel/Config/ContentTemplateValidationTest.php    | 5 +++++
 4 files changed, 10 insertions(+), 2 deletions(-)

diff --git a/src/Entity/ContentTemplate.php b/src/Entity/ContentTemplate.php
index b3fcdd0fde..807890e4bc 100644
--- a/src/Entity/ContentTemplate.php
+++ b/src/Entity/ContentTemplate.php
@@ -104,7 +104,7 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt
    *
    * @var ?array<string, array{'component_uuid': string, 'slot_name': string, 'label': string}>
    */
-  protected ?array $exposed_slots = [];
+  protected array $exposed_slots = [];
 
   /**
    * Tries to load a template for a particular entity, in a specific view mode.
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
index a706ac247f..c6059dd93d 100644
--- a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
@@ -9,11 +9,13 @@ use Drupal\Core\Validation\Attribute\Constraint;
 use Symfony\Component\Validator\Constraint as SymfonyConstraint;
 
 #[Constraint(
-  id: 'ValidExposedSlot',
+  id: self::PLUGIN_ID,
   label: new TranslatableMarkup('Validates an exposed slot', [], ['context' => 'Validation']),
 )]
 final class ValidExposedSlotConstraint extends SymfonyConstraint {
 
+  public const string PLUGIN_ID = 'ValidExposedSlot';
+
   public string $rootExposedMessage = 'Exposing the full component tree is not allowed.';
 
   public string $unknownComponentMessage = 'The component %id does not exist in the tree.';
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
index c6baa3409d..960fe2c1b8 100644
--- a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
@@ -27,6 +27,7 @@ final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
     // The root UUID (i.e., the entire component tree) cannot be exposed.
     if ($value['component_uuid'] === ComponentTreeStructure::ROOT_UUID) {
       $this->context->addViolation($constraint->rootExposedMessage);
+      return;
     }
 
     $root = $this->context->getRoot()->getEntity();
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 01d65b9266..7ff49f8303 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -29,6 +29,11 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
    */
   protected bool $hasLabel = FALSE;
 
+  /**
+   * {@inheritdoc}
+   */
+  protected static array $propertiesWithOptionalValues = ['exposed_slots'];
+
   /**
    * {@inheritdoc}
    */
-- 
GitLab


From b49a1ade395bb08bd0af93f6a483f9712f2b1d17 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 15 Apr 2025 15:10:11 -0400
Subject: [PATCH 08/17] Validate view mode slot

---
 config/schema/experience_builder.schema.yml   | 44 ++++++++-----------
 .../Constraint/ValidExposedSlotConstraint.php | 16 +++++++
 .../ValidExposedSlotConstraintValidator.php   | 10 ++++-
 .../Config/ContentTemplateValidationTest.php  | 33 ++++++++++++++
 4 files changed, 76 insertions(+), 27 deletions(-)

diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml
index dbdf29efba..d81df6ecef 100644
--- a/config/schema/experience_builder.schema.yml
+++ b/config/schema/experience_builder.schema.yml
@@ -560,7 +560,25 @@ experience_builder.content_template.*.*.*:
       sequence:
         type: mapping
         constraints:
-          ValidExposedSlot: ~
+          # Exposed slots are only supported by the "canonical" view mode, which is generally
+          # `full`. Example:
+          #
+          # exposed_slots:
+          #  profile_bio:
+          #    label: 'Profile Bio!'
+          #    component_uuid: 28bcab26-e434-4ad4-9eaf-0520bdb32fcc
+          #    slot_name: column_two
+          #  intro:
+          #    label: 'Intro'
+          #    component_uuid: 98bcab26-e434-4ad4-9eaf-0520bdb32fcc
+          #    slot_name: body
+          #
+          # @see \Drupal\Core\Entity\Controller\EntityViewController::view()
+          #
+          # @todo Core entity types ship with a `full` view mode, but it's not guaranteed to
+          #   exist for every content entity type. Ensure XB creates it when opting a content
+          #   entity bundle into XB rendering in https://www.drupal.org/i/3518248.
+          ValidExposedSlot: full
         mapping:
           # The UUID of the component that contains the slot being exposed. This CANNOT
           # be the same as \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure::ROOT_UUID,
@@ -576,27 +594,3 @@ experience_builder.content_template.*.*.*:
           label:
             type: required_label
             label: 'A human-readable label for the exposed slot'
-
-# Exposed slots are only supported by the "canonical" view mode, which is generally
-# `full`. Example:
-#
-# exposed_slots:
-#  profile_bio:
-#    label: 'Profile Bio!'
-#    component_uuid: 28bcab26-e434-4ad4-9eaf-0520bdb32fcc
-#    slot_name: column_two
-#  intro:
-#    label: 'Intro'
-#    component_uuid: 98bcab26-e434-4ad4-9eaf-0520bdb32fcc
-#    slot_name: body
-#
-# @see \Drupal\Core\Entity\Controller\EntityViewController::view()
-#
-# @todo Core entity types ship with a `full` view mode, but it's not guaranteed to
-#   exist for every content entity type. Ensure XB creates it when opting a content
-#   entity bundle into XB rendering in https://www.drupal.org/i/3518248.
-#experience_builder.content_template.*.*.full:
-#  type: experience_builder.content_template.*.*.*
-#  constraints:
-#    FullyValidatable: ~
-#  mapping:
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
index c6059dd93d..a71b7e7682 100644
--- a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
@@ -16,6 +16,13 @@ final class ValidExposedSlotConstraint extends SymfonyConstraint {
 
   public const string PLUGIN_ID = 'ValidExposedSlot';
 
+  /**
+   * The view mode in which the exposed slots will be used.
+   *
+   * @var string
+   */
+  public string $viewMode = 'full';
+
   public string $rootExposedMessage = 'Exposing the full component tree is not allowed.';
 
   public string $unknownComponentMessage = 'The component %id does not exist in the tree.';
@@ -24,4 +31,13 @@ final class ValidExposedSlotConstraint extends SymfonyConstraint {
 
   public string $undefinedSlotMessage = 'The component %id does not have a %slot slot.';
 
+  public string $viewModeMismatchMessage = 'Exposed slots are only allowed in the %mode view mode.';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDefaultOption(): string {
+    return 'viewMode';
+  }
+
 }
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
index 960fe2c1b8..355859e6b0 100644
--- a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
@@ -5,7 +5,7 @@ declare(strict_types=1);
 namespace Drupal\experience_builder\Plugin\Validation\Constraint;
 
 use Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface;
-use Drupal\experience_builder\Entity\ComponentTreeEntityInterface;
+use Drupal\experience_builder\Entity\ContentTemplate;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Symfony\Component\Validator\Constraint;
 use Symfony\Component\Validator\ConstraintValidator;
@@ -31,7 +31,7 @@ final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
     }
 
     $root = $this->context->getRoot()->getEntity();
-    assert($root instanceof ComponentTreeEntityInterface);
+    assert($root instanceof ContentTemplate);
     /** @var \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure $tree */
     $tree = $root->getComponentTree()->get('tree');
 
@@ -68,6 +68,12 @@ final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
         break;
       }
     }
+
+    if ($root->getMode() !== $constraint->viewMode) {
+      $this->context->addViolation($constraint->viewModeMismatchMessage, [
+        '%mode' => $constraint->viewMode,
+      ]);
+    }
   }
 
 }
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 7ff49f8303..17b6db9099 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -459,4 +459,37 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
     ]);
   }
 
+  public function testExposedSlotsOnlyAllowedInFullViewMode(): void {
+    $this->entity = ContentTemplate::create([
+      'content_entity_type_id' => 'node',
+      'content_entity_type_bundle' => 'alpha',
+      'content_entity_type_view_mode' => 'teaser',
+      'component_tree' => [
+        'tree' => self::encodeXBData([
+          ComponentTreeStructure::ROOT_UUID => [
+            ['uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', 'component' => 'sdc.xb_test_sdc.props-slots'],
+          ],
+        ]),
+        'inputs' => self::encodeXBData([
+          'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef' => [
+            'heading' => [
+              'sourceType' => 'dynamic',
+              'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
+            ],
+          ],
+        ]),
+      ],
+      'exposed_slots' => [
+        'footer_for_you' => [
+          'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef',
+          'slot_name' => 'the_footer',
+          'label' => "I got your footer right here",
+        ],
+      ],
+    ]);
+    $this->assertValidationErrors([
+      'exposed_slots.footer_for_you' => 'Exposed slots are only allowed in the <em class="placeholder">full</em> view mode.',
+    ]);
+  }
+
 }
-- 
GitLab


From 798b0cc6c7409c28949de336f9349b18327d5bb3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 15 Apr 2025 15:19:27 -0400
Subject: [PATCH 09/17] Fix a small PHPStan complaint

---
 src/Entity/ContentTemplate.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Entity/ContentTemplate.php b/src/Entity/ContentTemplate.php
index 807890e4bc..5bce583beb 100644
--- a/src/Entity/ContentTemplate.php
+++ b/src/Entity/ContentTemplate.php
@@ -102,7 +102,7 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt
   /**
    * The exposed slots.
    *
-   * @var ?array<string, array{'component_uuid': string, 'slot_name': string, 'label': string}>
+   * @var array<string, array{'component_uuid': string, 'slot_name': string, 'label': string}>
    */
   protected array $exposed_slots = [];
 
-- 
GitLab


From 5c8149040ea9cff7474943774a0fba713fcd8cbc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 15 Apr 2025 15:40:20 -0400
Subject: [PATCH 10/17] Fix test failures

---
 src/Entity/ContentTemplate.php                         |  4 ++--
 .../Kernel/Config/ContentTemplateValidationTest.php    | 10 +---------
 2 files changed, 3 insertions(+), 11 deletions(-)

diff --git a/src/Entity/ContentTemplate.php b/src/Entity/ContentTemplate.php
index 5bce583beb..b3fcdd0fde 100644
--- a/src/Entity/ContentTemplate.php
+++ b/src/Entity/ContentTemplate.php
@@ -102,9 +102,9 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt
   /**
    * The exposed slots.
    *
-   * @var array<string, array{'component_uuid': string, 'slot_name': string, 'label': string}>
+   * @var ?array<string, array{'component_uuid': string, 'slot_name': string, 'label': string}>
    */
-  protected array $exposed_slots = [];
+  protected ?array $exposed_slots = [];
 
   /**
    * Tries to load a template for a particular entity, in a specific view mode.
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 17b6db9099..078c96afaa 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -29,11 +29,6 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
    */
   protected bool $hasLabel = FALSE;
 
-  /**
-   * {@inheritdoc}
-   */
-  protected static array $propertiesWithOptionalValues = ['exposed_slots'];
-
   /**
    * {@inheritdoc}
    */
@@ -371,10 +366,7 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
       ],
     ]);
     $this->assertValidationErrors([
-      'exposed_slots.not_allowed' => [
-        'Exposing the full component tree is not allowed.',
-        'The component <em class="placeholder">a548b48d-58a8-4077-aa04-da9405a6f418</em> does not exist in the tree.',
-      ],
+      'exposed_slots.not_allowed' => 'Exposing the full component tree is not allowed.',
     ]);
   }
 
-- 
GitLab


From 85ec3000085dc84cf42444d9d82c7042647a1a3e Mon Sep 17 00:00:00 2001
From: Adam G-H <32250-phenaproxima@users.noreply.drupalcode.org>
Date: Wed, 16 Apr 2025 14:15:10 +0000
Subject: [PATCH 11/17] Apply 1 suggestion(s) to 1 file(s)

Co-authored-by: Wim Leers <44946-wimleers@users.noreply.drupalcode.org>
---
 config/schema/experience_builder.schema.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml
index d81df6ecef..c27928f90c 100644
--- a/config/schema/experience_builder.schema.yml
+++ b/config/schema/experience_builder.schema.yml
@@ -583,8 +583,8 @@ experience_builder.content_template.*.*.*:
           # The UUID of the component that contains the slot being exposed. This CANNOT
           # be the same as \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure::ROOT_UUID,
           # because that would mean the entire template could be overridden by an individual
-          # content entity, which is not an appropriate use of a content template (we have
-          # a whole distinct landing page content entity type for that).
+          # content entity, which is not an appropriate use of a content template (XB has
+          # the `Page` content entity type for that).
           component_uuid:
             type: uuid
             label: 'UUID of the component instance that contains the exposed slot'
-- 
GitLab


From 25d9d0543a6f7ffc12b0748b5bc0401b429a3aef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Wed, 16 Apr 2025 10:58:42 -0400
Subject: [PATCH 12/17] Move todo

---
 config/schema/experience_builder.schema.yml | 12 +++++-------
 1 file changed, 5 insertions(+), 7 deletions(-)

diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml
index c27928f90c..6ffcd962c3 100644
--- a/config/schema/experience_builder.schema.yml
+++ b/config/schema/experience_builder.schema.yml
@@ -524,6 +524,9 @@ experience_builder.content_template.*.*.*:
       constraints:
         # @todo Use `ConfigExists` once https://www.drupal.org/project/drupal/issues/3518273 is
         #   fixed in core.
+        # @todo Core entity types ship with a `full` view mode, but it's not guaranteed to
+        #   exist for every content entity type. Ensure XB creates it when opting a content
+        #   entity bundle into XB rendering in https://www.drupal.org/i/3518248.
         BetterConfigExists:
           prefix: 'core.entity_view_mode.[%parent.content_entity_type_id].'
     component_tree:
@@ -561,7 +564,8 @@ experience_builder.content_template.*.*.*:
         type: mapping
         constraints:
           # Exposed slots are only supported by the "canonical" view mode, which is generally
-          # `full`. Example:
+          # assumed to be `full` (see \Drupal\Core\Entity\Controller\EntityViewController::view()
+          # for where core makes this assumption). Example:
           #
           # exposed_slots:
           #  profile_bio:
@@ -572,12 +576,6 @@ experience_builder.content_template.*.*.*:
           #    label: 'Intro'
           #    component_uuid: 98bcab26-e434-4ad4-9eaf-0520bdb32fcc
           #    slot_name: body
-          #
-          # @see \Drupal\Core\Entity\Controller\EntityViewController::view()
-          #
-          # @todo Core entity types ship with a `full` view mode, but it's not guaranteed to
-          #   exist for every content entity type. Ensure XB creates it when opting a content
-          #   entity bundle into XB rendering in https://www.drupal.org/i/3518248.
           ValidExposedSlot: full
         mapping:
           # The UUID of the component that contains the slot being exposed. This CANNOT
-- 
GitLab


From 75dcc7745ac4378d62e6bf31ef7d1c3eb03443f7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Wed, 16 Apr 2025 11:27:47 -0400
Subject: [PATCH 13/17] Refactor the test partially

---
 src/Entity/ContentTemplate.php                |   2 +-
 .../Config/ContentTemplateValidationTest.php  | 197 ++++++++----------
 2 files changed, 90 insertions(+), 109 deletions(-)

diff --git a/src/Entity/ContentTemplate.php b/src/Entity/ContentTemplate.php
index b3fcdd0fde..f3b6bf85ef 100644
--- a/src/Entity/ContentTemplate.php
+++ b/src/Entity/ContentTemplate.php
@@ -69,7 +69,7 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt
    *
    * @see \Drupal\experience_builder\Plugin\Validation\Constraint\StringPartsConstraint
    */
-  protected string $id;
+  protected ?string $id;
 
   /**
    * Entity type to be displayed.
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 078c96afaa..be3b27d623 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -87,42 +87,51 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
         'tree' => self::encodeXBData([
           ComponentTreeStructure::ROOT_UUID => [
             // An SDC populated by static prop sources.
-            ['uuid' => 'sdc-static', 'component' => 'sdc.sdc_test.my-cta'],
+            ['uuid' => '4d537f4f-d2dc-487f-92a2-7143babb0ea3', 'component' => 'sdc.sdc_test.my-cta'],
             // A code component populated by an entity base field.
-            ['uuid' => 'code-dynamic-base-field', 'component' => 'js.my-cta'],
+            ['uuid' => 'ef1e9d37-8d76-476e-b39f-37b4dd5a4767', 'component' => 'js.my-cta'],
             // An SDC populated by a normal entity field.
-            ['uuid' => 'sdc-dynamic-bundle-field', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
+            ['uuid' => '3b8b5d79-e133-4360-a064-7a89aab99386', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
             // A block component.
-            ['uuid' => 'block', 'component' => 'block.system_branding_block'],
+            ['uuid' => '2f4c5088-c5b9-4d1c-ac57-79f1f3c78dee', 'component' => 'block.system_branding_block'],
+            // An SDC with a slot that can be exposed.
+            ['uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', 'component' => 'sdc.xb_test_sdc.props-slots'],
           ],
         ]),
         'inputs' => self::encodeXBData([
-          'sdc-static' => [
+          '4d537f4f-d2dc-487f-92a2-7143babb0ea3' => [
             'text' => [
               'sourceType' => 'static:field_item:string',
               'value' => 'This is really tricky for a first-timer',
               'expression' => 'ℹ︎string␟value',
             ],
           ],
-          'code-dynamic-base-field' => [
+          'ef1e9d37-8d76-476e-b39f-37b4dd5a4767' => [
             'text' => [
               'sourceType' => 'dynamic',
               'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
             ],
           ],
-          'sdc-dynamic-bundle-field' => [
+          '3b8b5d79-e133-4360-a064-7a89aab99386' => [
             'heading' => [
               'sourceType' => 'dynamic',
               'expression' => 'ℹ︎␜entity:node:alpha␝field_test␞␟value',
             ],
           ],
-          'block' => [
+          '2f4c5088-c5b9-4d1c-ac57-79f1f3c78dee' => [
             'label' => '',
             'label_display' => FALSE,
             'use_site_logo' => FALSE,
             'use_site_name' => TRUE,
             'use_site_slogan' => TRUE,
           ],
+          'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef' => [
+            'heading' => [
+              'sourceType' => 'static:field_item:string',
+              'value' => 'There be a slot here',
+              'expression' => 'ℹ︎string␟value',
+            ],
+          ],
         ]),
       ],
     ]);
@@ -148,6 +157,7 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
           'experience_builder.component.js.my-cta',
           'experience_builder.component.sdc.sdc_test.my-cta',
           'experience_builder.component.sdc.xb_test_sdc.props-no-slots',
+          'experience_builder.component.sdc.xb_test_sdc.props-slots',
         ],
       ],
       $this->entity->getDependencies()
@@ -159,6 +169,7 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
         'experience_builder.component.js.my-cta',
         'experience_builder.component.sdc.sdc_test.my-cta',
         'experience_builder.component.sdc.xb_test_sdc.props-no-slots',
+        'experience_builder.component.sdc.xb_test_sdc.props-slots',
         'experience_builder.js_component.my-cta',
       ],
       'module' => [
@@ -357,63 +368,29 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
     ]);
   }
 
-  public function testRootUuidCannotBeExposed(): void {
-    $this->entity->set('exposed_slots', [
-      'not_allowed' => [
-        'component_uuid' => ComponentTreeStructure::ROOT_UUID,
-        'slot_name' => 'not-a-thing',
-        'label' => "This won't work",
-      ],
-    ]);
-    $this->assertValidationErrors([
-      'exposed_slots.not_allowed' => 'Exposing the full component tree is not allowed.',
-    ]);
-  }
-
-  public function testExposedSlotComponentMustExistInTree(): void {
-    $this->entity->set('exposed_slots', [
-      'not_a_thing' => [
-        'component_uuid' => 'f0a5a653-ee1c-4469-903a-119d70edbf02',
-        'slot_name' => 'not-a-thing',
-        'label' => "Can't expose a slot in a component we don't have!",
-      ],
-    ]);
-    $this->assertValidationErrors([
-      'exposed_slots.not_a_thing' => 'The component <em class="placeholder">f0a5a653-ee1c-4469-903a-119d70edbf02</em> does not exist in the tree.',
-    ]);
-  }
-
   public function testExposedSlotMustBeEmpty(): void {
+    assert($this->entity instanceof ContentTemplate);
+
+    // Add a component in one of the open slots.
+    $item = $this->entity->getComponentTree();
+    $tree = json_decode($item->get('tree')->getValue(), TRUE);
+    $tree['b4937e35-ddc2-4f36-8d4c-b1cc14aaefef']['the_footer'][] = [
+      'uuid' => 'greeting',
+      'component' => 'sdc.xb_test_sdc.props-no-slots',
+    ];
+    $inputs = json_decode($item->get('inputs')->getValue(), TRUE);
+    $inputs['greeting']['heading'] = [
+      'sourceType' => 'dynamic',
+      'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
+    ];
     $this->entity->set('component_tree', [
-      'tree' => self::encodeXBData([
-        ComponentTreeStructure::ROOT_UUID => [
-          ['uuid' => '67062b9a-b35f-453f-a66b-fdb9988b604b', 'component' => 'sdc.xb_test_sdc.props-slots'],
-        ],
-        '67062b9a-b35f-453f-a66b-fdb9988b604b' => [
-          'the_footer' => [
-            ['uuid' => 'greeting', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
-          ],
-        ],
-      ]),
-      'inputs' => self::encodeXBData([
-        '67062b9a-b35f-453f-a66b-fdb9988b604b' => [
-          'heading' => [
-            'sourceType' => 'static:field_item:string',
-            'value' => "My footer is showing",
-            'expression' => 'ℹ︎string␟value',
-          ],
-        ],
-        'greeting' => [
-          'heading' => [
-            'sourceType' => 'dynamic',
-            'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
-          ],
-        ],
-      ]),
+      'tree' => self::encodeXBData($tree),
+      'inputs' => self::encodeXBData($inputs),
     ]);
+
     $this->entity->set('exposed_slots', [
       'filled_footer' => [
-        'component_uuid' => '67062b9a-b35f-453f-a66b-fdb9988b604b',
+        'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef',
         'slot_name' => 'the_footer',
         'label' => "Something's already here!",
       ],
@@ -423,61 +400,65 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
     ]);
   }
 
-  public function testExposedSlotMustBeDefinedByComponent(): void {
-    $this->entity->set('component_tree', [
-      'tree' => self::encodeXBData([
-        ComponentTreeStructure::ROOT_UUID => [
-          ['uuid' => '20398504-ce6f-4a79-819e-405ea34eefc4', 'component' => 'sdc.xb_test_sdc.props-slots'],
-        ],
-      ]),
-      'inputs' => self::encodeXBData([
-        '20398504-ce6f-4a79-819e-405ea34eefc4' => [
-          'heading' => [
-            'sourceType' => 'dynamic',
-            'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
-          ],
+  public static function providerInvalidExposedSlot(): iterable {
+    yield 'root uuid is exposed' => [
+      [
+        'not_allowed' => [
+          'component_uuid' => ComponentTreeStructure::ROOT_UUID,
+          'slot_name' => 'not-a-thing',
+          'label' => "This won't work",
         ],
-      ]),
-    ]);
-    $this->entity->set('exposed_slots', [
-      'filled_footer' => [
-        'component_uuid' => '20398504-ce6f-4a79-819e-405ea34eefc4',
-        'slot_name' => 'not_a_real_slot',
-        'label' => "Whither this slot you speak of?",
       ],
-    ]);
-    $this->assertValidationErrors([
-      'exposed_slots.filled_footer' => 'The component <em class="placeholder">20398504-ce6f-4a79-819e-405ea34eefc4</em> does not have a <em class="placeholder">not_a_real_slot</em> slot.',
-    ]);
-  }
+      [
+        'exposed_slots.not_allowed' => 'Exposing the full component tree is not allowed.',
+      ],
+    ];
 
-  public function testExposedSlotsOnlyAllowedInFullViewMode(): void {
-    $this->entity = ContentTemplate::create([
-      'content_entity_type_id' => 'node',
-      'content_entity_type_bundle' => 'alpha',
-      'content_entity_type_view_mode' => 'teaser',
-      'component_tree' => [
-        'tree' => self::encodeXBData([
-          ComponentTreeStructure::ROOT_UUID => [
-            ['uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', 'component' => 'sdc.xb_test_sdc.props-slots'],
-          ],
-        ]),
-        'inputs' => self::encodeXBData([
-          'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef' => [
-            'heading' => [
-              'sourceType' => 'dynamic',
-              'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
-            ],
-          ],
-        ]),
+    yield 'component exposing the slot does not exist in the tree' => [
+      [
+        'not_a_thing' => [
+          'component_uuid' => '6348ee20-cf62-49e3-bc86-cf62abc09c74',
+          'slot_name' => 'not-a-thing',
+          'label' => "Can't expose a slot in a component we don't have!",
+        ],
       ],
-      'exposed_slots' => [
-        'footer_for_you' => [
+      [
+        'exposed_slots.not_a_thing' => 'The component <em class="placeholder">6348ee20-cf62-49e3-bc86-cf62abc09c74</em> does not exist in the tree.',
+      ],
+    ];
+
+    yield 'exposed slot is not defined by the component' => [
+      [
+        'filled_footer' => [
           'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef',
-          'slot_name' => 'the_footer',
-          'label' => "I got your footer right here",
+          'slot_name' => 'not_a_real_slot',
+          'label' => "Whither this slot you speak of?",
         ],
       ],
+      [
+        'exposed_slots.filled_footer' => 'The component <em class="placeholder">b4937e35-ddc2-4f36-8d4c-b1cc14aaefef</em> does not have a <em class="placeholder">not_a_real_slot</em> slot.',
+      ],
+    ];
+  }
+
+  /**
+   * @dataProvider providerInvalidExposedSlot
+   */
+  public function testInvalidExposedSlot(array $exposed_slots, array $expected_errors): void {
+    $this->entity->set('exposed_slots', $exposed_slots);
+    $this->assertValidationErrors($expected_errors);
+  }
+
+  public function testExposedSlotsOnlyAllowedInFullViewMode(): void {
+    $this->entity = $this->entity->createDuplicate();
+    $this->entity->set('content_entity_type_view_mode', 'teaser');
+    $this->entity->set('id', 'node.alpha.teaser');
+    $this->entity->set('exposed_slots', [
+      'footer_for_you' => [
+        'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef',
+        'slot_name' => 'the_footer',
+        'label' => "I got your footer right here",
+      ],
     ]);
     $this->assertValidationErrors([
       'exposed_slots.footer_for_you' => 'Exposed slots are only allowed in the <em class="placeholder">full</em> view mode.',
-- 
GitLab


From 06a54825ec25ca2985c19b17e21faa4d82dc086b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Wed, 16 Apr 2025 11:59:25 -0400
Subject: [PATCH 14/17] Validate slot machine names

---
 config/schema/experience_builder.schema.yml   |  4 +++
 .../SequenceKeysMatchRegexConstraint.php      | 19 +++++++++++
 ...uenceKeysMatchRegexConstraintValidator.php | 33 +++++++++++++++++++
 .../Config/ContentTemplateValidationTest.php  | 13 ++++++++
 4 files changed, 69 insertions(+)
 create mode 100644 src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php
 create mode 100644 src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php

diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml
index 6ffcd962c3..f0550642f6 100644
--- a/config/schema/experience_builder.schema.yml
+++ b/config/schema/experience_builder.schema.yml
@@ -592,3 +592,7 @@ experience_builder.content_template.*.*.*:
           label:
             type: required_label
             label: 'A human-readable label for the exposed slot'
+      constraints:
+        SequenceKeysMatchRegex:
+          pattern: '/^[a-z0-9_]+$/'
+          message: "The %value key is not a valid machine name."
diff --git a/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php
new file mode 100644
index 0000000000..8004db2ee0
--- /dev/null
+++ b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\experience_builder\Plugin\Validation\Constraint;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\Validation\Attribute\Constraint;
+use Symfony\Component\Validator\Constraints\Regex;
+
+#[Constraint(
+  id: self::PLUGIN_ID,
+  label: new TranslatableMarkup('Sequence keys match regex', options: ['context' => 'Validation']),
+)]
+final class SequenceKeysMatchRegexConstraint extends Regex {
+
+  public const string PLUGIN_ID = 'SequenceKeysMatchRegex';
+
+}
diff --git a/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php
new file mode 100644
index 0000000000..35044e5dd2
--- /dev/null
+++ b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\experience_builder\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\Constraints\RegexValidator;
+use Symfony\Component\Validator\Exception\UnexpectedTypeException;
+
+/**
+ * Validates the SequenceKeysMatchRegex constraint.
+ */
+final class SequenceKeysMatchRegexConstraintValidator extends RegexValidator {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate(mixed $value, Constraint $constraint): void {
+    if (!is_array($value)) {
+      throw new UnexpectedTypeException($value, 'array');
+    }
+
+    if ($value && array_is_list($value)) {
+      throw new UnexpectedTypeException($value, 'associative array');
+    }
+
+    foreach (array_keys($value) as $key) {
+      parent::validate($key, $constraint);
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index be3b27d623..37b14663d8 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -439,6 +439,19 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
         'exposed_slots.filled_footer' => 'The component <em class="placeholder">b4937e35-ddc2-4f36-8d4c-b1cc14aaefef</em> does not have a <em class="placeholder">not_a_real_slot</em> slot.',
       ],
     ];
+
+    yield 'exposed slot machine name is not valid' => [
+      [
+        'not a valid exposed slot name' => [
+          'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef',
+          'slot_name' => 'the_footer',
+          'label' => "I got your footer right here",
+        ],
+      ],
+      [
+        'exposed_slots' => 'The <em class="placeholder">&quot;not a valid exposed slot name&quot;</em> key is not a valid machine name.',
+      ],
+    ];
   }
 
   /**
-- 
GitLab


From c7b8b71589be4cc58fc238841d0e4f00f90d2a95 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Wed, 16 Apr 2025 14:02:45 -0400
Subject: [PATCH 15/17] Fix test

---
 .../Constraint/SequenceKeysMatchRegexConstraintValidator.php | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php
index 35044e5dd2..07f4b17379 100644
--- a/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php
+++ b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php
@@ -17,10 +17,13 @@ final class SequenceKeysMatchRegexConstraintValidator extends RegexValidator {
    * {@inheritdoc}
    */
   public function validate(mixed $value, Constraint $constraint): void {
+    // If the value isn't NULL, it needs to be an associative array.
+    if ($value === NULL) {
+      return;
+    }
     if (!is_array($value)) {
       throw new UnexpectedTypeException($value, 'array');
     }
-
     if ($value && array_is_list($value)) {
       throw new UnexpectedTypeException($value, 'associative array');
     }
-- 
GitLab


From 8796a48077e970afbfe0a4d932a5b180f2a3b2e1 Mon Sep 17 00:00:00 2001
From: Wim Leers <44946-wimleers@users.noreply.drupalcode.org>
Date: Fri, 18 Apr 2025 10:12:18 +0000
Subject: [PATCH 16/17] =?UTF-8?q?Clarifications=20WRT=20"exposed=20slot=20?=
 =?UTF-8?q?name"=20vs=20"component=20slot=20name"=C2=A0=E2=80=94=20they=20?=
 =?UTF-8?q?are=20NOT=20the=20same=20thing!?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 config/schema/experience_builder.schema.yml | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml
index f0550642f6..19722e2bc5 100644
--- a/config/schema/experience_builder.schema.yml
+++ b/config/schema/experience_builder.schema.yml
@@ -587,12 +587,14 @@ experience_builder.content_template.*.*.*:
             type: uuid
             label: 'UUID of the component instance that contains the exposed slot'
           slot_name:
+            # @todo Add constraint in https://www.drupal.org/project/experience_builder/issues/3519891
             type: string
-            label: 'The name of the slot exposed slot, as known to the component'
+            label: 'The machine name of the component slot being exposed'
           label:
             type: required_label
             label: 'A human-readable label for the exposed slot'
       constraints:
         SequenceKeysMatchRegex:
+          # Note: this is a subset of what slot names are allowed by SDCs: the machine names of XB's exposed content template slots must be simpler.
           pattern: '/^[a-z0-9_]+$/'
           message: "The %value key is not a valid machine name."
-- 
GitLab


From c5b8f77a19049ade8f502c13bc73e3505c8c63d8 Mon Sep 17 00:00:00 2001
From: Wim Leers <wim.leers@acquia.com>
Date: Fri, 18 Apr 2025 12:20:45 +0200
Subject: [PATCH 17/17] Revert bits of 4c246eae to keep the test equally easy
 to read and reduce the diff compared to HEAD.

---
 .../Config/ContentTemplateValidationTest.php     | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 37b14663d8..f754e97841 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -87,38 +87,38 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
         'tree' => self::encodeXBData([
           ComponentTreeStructure::ROOT_UUID => [
             // An SDC populated by static prop sources.
-            ['uuid' => '4d537f4f-d2dc-487f-92a2-7143babb0ea3', 'component' => 'sdc.sdc_test.my-cta'],
+            ['uuid' => 'sdc-static', 'component' => 'sdc.sdc_test.my-cta'],
             // A code component populated by an entity base field.
-            ['uuid' => 'ef1e9d37-8d76-476e-b39f-37b4dd5a4767', 'component' => 'js.my-cta'],
+            ['uuid' => 'code-dynamic-base-field', 'component' => 'js.my-cta'],
             // An SDC populated by a normal entity field.
-            ['uuid' => '3b8b5d79-e133-4360-a064-7a89aab99386', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
+            ['uuid' => 'sdc-dynamic-bundle-field', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
             // A block component.
-            ['uuid' => '2f4c5088-c5b9-4d1c-ac57-79f1f3c78dee', 'component' => 'block.system_branding_block'],
+            ['uuid' => 'block', 'component' => 'block.system_branding_block'],
             // An SDC with a slot that can be exposed.
             ['uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', 'component' => 'sdc.xb_test_sdc.props-slots'],
           ],
         ]),
         'inputs' => self::encodeXBData([
-          '4d537f4f-d2dc-487f-92a2-7143babb0ea3' => [
+          'sdc-static' => [
             'text' => [
               'sourceType' => 'static:field_item:string',
               'value' => 'This is really tricky for a first-timer',
               'expression' => 'ℹ︎string␟value',
             ],
           ],
-          'ef1e9d37-8d76-476e-b39f-37b4dd5a4767' => [
+          'code-dynamic-base-field' => [
             'text' => [
               'sourceType' => 'dynamic',
               'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
             ],
           ],
-          '3b8b5d79-e133-4360-a064-7a89aab99386' => [
+          'sdc-dynamic-bundle-field' => [
             'heading' => [
               'sourceType' => 'dynamic',
               'expression' => 'ℹ︎␜entity:node:alpha␝field_test␞␟value',
             ],
           ],
-          '2f4c5088-c5b9-4d1c-ac57-79f1f3c78dee' => [
+          'block' => [
             'label' => '',
             'label_display' => FALSE,
             'use_site_logo' => FALSE,
-- 
GitLab