From 0f9e8a8e720aa1bf33545aadd666fba85ea61625 Mon Sep 17 00:00:00 2001
From: Pierre Dureau <31905-pdureau@users.noreply.drupalcode.org>
Date: Mon, 9 Sep 2024 13:57:45 +0000
Subject: [PATCH] Issue #3470231 by pdureau, just_like_good_vibes, g4mbini,
 grimreaper, smustgrave: Attributes normalization

---
 .../ui-patterns-component-table.html.twig     |   2 +-
 src/ComponentPluginManager.php                |  27 +++-
 src/Element/ComponentElementAlter.php         |  55 +++++--
 .../PropType/AttributesPropType.php           |  21 ++-
 .../UiPatterns/PropType/BooleanPropType.php   |   8 +
 .../UiPatterns/PropType/LinksPropType.php     |  32 +++-
 .../UiPatterns/Source/AttributesWidget.php    |  30 +++-
 src/PropTypeInterface.php                     |  15 +-
 src/PropTypePluginBase.php                    |   7 +
 src/Template/ComponentNodeVisitor.php         | 146 ++++++++++++++++++
 src/Template/TwigExtension.php                |  53 ++++++-
 .../LinksPropTypeNormalizationTest.php        |  37 ++---
 ui_patterns.services.yml                      |   2 +
 13 files changed, 377 insertions(+), 58 deletions(-)
 create mode 100755 src/Template/ComponentNodeVisitor.php

diff --git a/modules/ui_patterns_library/templates/ui-patterns-component-table.html.twig b/modules/ui_patterns_library/templates/ui-patterns-component-table.html.twig
index db15f500a..127fbf261 100644
--- a/modules/ui_patterns_library/templates/ui-patterns-component-table.html.twig
+++ b/modules/ui_patterns_library/templates/ui-patterns-component-table.html.twig
@@ -26,7 +26,7 @@
         </tr>
       {% endfor %}
       {% for prop_id, prop in component.props.properties %}
-        {% if prop_id != 'attributes' %}
+        {% if prop_id != 'attributes' and prop_id != 'variant' %}
           <tr class="ui_patterns_component_table__prop">
             <td>
               <code>{{ prop_id }}</code>
diff --git a/src/ComponentPluginManager.php b/src/ComponentPluginManager.php
index a02f69406..8217d1296 100644
--- a/src/ComponentPluginManager.php
+++ b/src/ComponentPluginManager.php
@@ -143,9 +143,7 @@ class ComponentPluginManager extends SdcPluginManager implements CategorizingPlu
     if (isset($definition["variants"])) {
       $definition['props']['properties']['variant'] = $this->buildVariantProp($definition);
     }
-    if (!isset($definition['props']['properties'])) {
-      return $definition;
-    }
+    $definition['props']['properties'] = $this->addAttributesProp($definition);
     foreach ($definition['props']['properties'] as $prop_id => $prop) {
       $definition['props']['properties'][$prop_id] = $this->annotateProp($prop_id, $prop, $fallback_prop_type_id);
     }
@@ -186,8 +184,31 @@ class ComponentPluginManager extends SdcPluginManager implements CategorizingPlu
     return $prop;
   }
 
+  /**
+   * Add attributes prop.
+   *
+   * 'attribute' is one of the 2 'magic' props: its name and type are already
+   * set. Always available because automatically added by
+   * ComponentsTwigExtension::mergeAdditionalRenderContext().
+   */
+  private function addAttributesProp(array $definition): array {
+    // Let's put it at the beginning (for forms).
+    return array_merge(
+     [
+       'attributes' => [
+         'title' => 'Attributes',
+         '$ref' => "ui-patterns://attributes",
+       ],
+     ],
+      $definition['props']['properties'] ?? [],
+    );
+  }
+
   /**
    * Build variant prop.
+   *
+   * 'variant' is one of the 2 'magic' props: its name and type are already set.
+   * Available if at least a variant is set in the component definition.
    */
   private function buildVariantProp(array $definition): array {
     $enums = [];
diff --git a/src/Element/ComponentElementAlter.php b/src/Element/ComponentElementAlter.php
index 3fb343ce6..70f43f295 100644
--- a/src/Element/ComponentElementAlter.php
+++ b/src/Element/ComponentElementAlter.php
@@ -4,8 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\ui_patterns\Element;
 
+use Drupal\Core\Plugin\Component;
 use Drupal\Core\Security\TrustedCallbackInterface;
-use Drupal\Core\Template\Attribute;
 use Drupal\Core\Theme\ComponentPluginManager;
 use Drupal\ui_patterns\Plugin\UiPatterns\PropType\SlotPropType;
 
@@ -31,6 +31,18 @@ class ComponentElementAlter implements TrustedCallbackInterface {
    * Alter SDC component element.
    */
   public function alter(array $element): array {
+    $element = $this->normalizeSlots($element);
+    $component = $this->componentPluginManager->find($element['#component']);
+    $element = $this->processAttributesProp($element, $component);
+    $element = $this->processAttributesRenderProperty($element);
+    $element = $this->normalizeProps($element, $component);
+    return $element;
+  }
+
+  /**
+   * Normalize slots.
+   */
+  public function normalizeSlots(array $element): array {
     foreach ($element["#slots"] as $slot_id => $slot) {
       // Because SDC validator is sometimes confused by a null slot.
       if (is_null($slot)) {
@@ -44,8 +56,13 @@ class ComponentElementAlter implements TrustedCallbackInterface {
       }
       $element["#slots"][$slot_id] = SlotPropType::normalize($slot);
     }
-    $component = $this->componentPluginManager->find($element['#component']);
-    $element = $this->processAttributes($element);
+    return $element;
+  }
+
+  /**
+   * Normalize props.
+   */
+  public function normalizeProps(array $element, Component $component): array {
     $props = $component->metadata->schema['properties'] ?? [];
     foreach ($element["#props"] as $prop_id => $prop) {
       if (!isset($props[$prop_id])) {
@@ -58,7 +75,25 @@ class ComponentElementAlter implements TrustedCallbackInterface {
   }
 
   /**
-   * Process #attributes property.
+   * Process attributes prop.
+   */
+  public function processAttributesProp(array $element, Component $component): array {
+    $element["#props"]["attributes"] = $element["#props"]["attributes"] ?? [];
+    // Attribute PHP objects are rendered as strings by SDC ComponentValidator,
+    // this is raising an error: "InvalidComponentException: String value
+    // found, but an object is required".
+    if (is_a($element["#props"]["attributes"], '\Drupal\Core\Template\Attribute')) {
+      $element["#props"]["attributes"] = $element["#props"]["attributes"]->toArray();
+    }
+    // Attributes prop must never be empty, to avoid the processing of SDC's
+    // ComponentsTwigExtension::mergeAdditionalRenderContext() which is adding
+    // an Attribute PHP object.
+    $element["#props"]["attributes"]['data-component-id'] = $component->getPluginId();
+    return $element;
+  }
+
+  /**
+   * Process #attributes render property.
    *
    * #attributes property is an universal property of the Render API, used by
    * many Drupal mechanisms from Core and Contrib, but not processed by SDC
@@ -66,15 +101,17 @@ class ComponentElementAlter implements TrustedCallbackInterface {
    *
    * @todo Move this to Drupal Core.
    */
-  public function processAttributes(array $element): array {
+  public function processAttributesRenderProperty(array $element): array {
     if (!isset($element["#attributes"])) {
       return $element;
     }
-    $attributes = new Attribute($element["#attributes"]);
-    if (isset($element["#props"]["attributes"])) {
-      $attributes = (new Attribute($element["#props"]["attributes"]))->merge($attributes);
+    if (is_a($element["#attributes"], '\Drupal\Core\Template\Attribute')) {
+      $element["#attributes"] = $element["#attributes"]->toArray();
     }
-    $element["#props"]["attributes"] = $attributes;
+    $element["#props"]["attributes"] = array_merge(
+      $element["#attributes"],
+      $element["#props"]["attributes"]
+    );
     return $element;
   }
 
diff --git a/src/Plugin/UiPatterns/PropType/AttributesPropType.php b/src/Plugin/UiPatterns/PropType/AttributesPropType.php
index e68252d38..2dfe45a9d 100644
--- a/src/Plugin/UiPatterns/PropType/AttributesPropType.php
+++ b/src/Plugin/UiPatterns/PropType/AttributesPropType.php
@@ -48,20 +48,33 @@ class AttributesPropType extends PropTypePluginBase {
     Attributes are defined as a mapping ('object' in JSON schema). So, source
     plugins are expected to return a mapping to not break SDC prop validation
     against the prop type schema.
+     */
+    if (is_a($value, '\Drupal\Core\Template\Attribute')) {
+      return $value->toArray();
+    }
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function preprocess(mixed $value): mixed {
+    /*
     However, when they land in the template, it is safer to have them as
     Attribute objects:
     - if the template use create_attribute(), it will not break thanks to
     "#3403331: Prevent TypeError when using create_attribute Twig function"
     - if the template directly calls object methods, it will work because it
     is already an object
-    - ArrayAccess interface allows manipulation as an array,
-    It is possible because PropTypeInterface::normalize() is called by
-    ComponentElementAlter::alter() after SDC is doing the validation.
+    - ArrayAccess interface allows manipulation as an array.
      */
+    if (is_a($value, '\Drupal\Core\Template\Attribute')) {
+      return $value;
+    }
     if (is_array($value)) {
       return new Attribute($value);
     }
-    return $value;
+    return new Attribute();
   }
 
 }
diff --git a/src/Plugin/UiPatterns/PropType/BooleanPropType.php b/src/Plugin/UiPatterns/PropType/BooleanPropType.php
index 99037e586..e77f85eed 100644
--- a/src/Plugin/UiPatterns/PropType/BooleanPropType.php
+++ b/src/Plugin/UiPatterns/PropType/BooleanPropType.php
@@ -21,4 +21,12 @@ use Drupal\ui_patterns\PropTypePluginBase;
   typed_data: ['boolean']
 )]
 class BooleanPropType extends PropTypePluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function normalize(mixed $value): mixed {
+    return (bool) $value;
+  }
+
 }
diff --git a/src/Plugin/UiPatterns/PropType/LinksPropType.php b/src/Plugin/UiPatterns/PropType/LinksPropType.php
index 2d241098d..7ba20aa91 100644
--- a/src/Plugin/UiPatterns/PropType/LinksPropType.php
+++ b/src/Plugin/UiPatterns/PropType/LinksPropType.php
@@ -136,6 +136,9 @@ class LinksPropType extends PropTypePluginBase implements ContainerFactoryPlugin
     if (!array_key_exists($property, $item)) {
       return $item;
     }
+    if (is_a($item[$property], '\Drupal\Core\Template\Attribute')) {
+      $item[$property] = $item[$property]->toArray();
+    }
     // Empty PHP arrays are converted in JSON arrays instead of JSON objects
     // by json_encode(), so it is better to remove them.
     if (is_array($item[$property]) && empty($item[$property])) {
@@ -201,6 +204,9 @@ class LinksPropType extends PropTypePluginBase implements ContainerFactoryPlugin
       $item["link_attributes"] = $url_attributes;
       return $item;
     }
+    if (is_a($item["link_attributes"], '\Drupal\Core\Template\Attribute')) {
+      $item["link_attributes"] = $item["link_attributes"]->toArray();
+    }
     if (is_array($item["link_attributes"])) {
       $item["link_attributes"] = array_merge(
         $item["link_attributes"],
@@ -208,10 +214,6 @@ class LinksPropType extends PropTypePluginBase implements ContainerFactoryPlugin
       );
       return $item;
     }
-    if (is_a($item["link_attributes"], '\Drupal\Core\Template\Attribute')) {
-      $item["link_attributes"]->merge(new Attribute($url_attributes));
-      return $item;
-    }
     return $item;
   }
 
@@ -274,4 +276,26 @@ class LinksPropType extends PropTypePluginBase implements ContainerFactoryPlugin
     }
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public static function preprocess(mixed $value): mixed {
+    foreach ($value as $index => &$item) {
+      if (!is_array($item)) {
+        continue;
+      }
+      if (array_key_exists("attributes", $item) && is_array($item['attributes'])) {
+        $item["attributes"] = new Attribute($item["attributes"]);
+      }
+      if (array_key_exists("link_attributes", $item) && is_array($item['link_attributes'])) {
+        $item["link_attributes"] = new Attribute($item["link_attributes"]);
+      }
+      if (array_key_exists("below", $item)) {
+        $item["below"] = self::preprocess($item["below"]);
+      }
+      $value[$index] = $item;
+    }
+    return $value;
+  }
+
 }
diff --git a/src/Plugin/UiPatterns/Source/AttributesWidget.php b/src/Plugin/UiPatterns/Source/AttributesWidget.php
index 6e0650d3b..15741cb26 100644
--- a/src/Plugin/UiPatterns/Source/AttributesWidget.php
+++ b/src/Plugin/UiPatterns/Source/AttributesWidget.php
@@ -47,17 +47,33 @@ class AttributesWidget extends SourcePluginBase {
       '#type' => 'textfield',
       '#default_value' => $this->getSetting('value'),
     ];
-    // Allow anything in attributes values, which are between simple and double
-    // quotes. Forbid some characters in attributes names.
-    // See https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0
-    $forbidden_characters = "<>&/`";
-    $pattern = "^(([^" . $forbidden_characters . "]+)=[\"'].+[\"']\s*)*?$";
-    $form['value']['#pattern'] = $pattern;
-    $form['value']['#description'] = $this->t("Values must be present and quoted.");
+    $form['value']['#pattern'] = $this->buildRegexPattern();
+    // To allow form errors to be displayed correctly.
+    $form['value']['#title'] = '';
+    $form['value']['#placeholder'] = 'class="hidden" title="Lorem ipsum"';
+    $form['value']['#description'] = $this->t("HTML attributes with double-quoted values.");
     $this->addRequired($form['value']);
     return $form;
   }
 
+  /**
+   * Build regular expression pattern.
+   *
+   * See https://html.spec.whatwg.org/#attributes-2
+   */
+  protected function buildRegexPattern(): string {
+    // Attribute names are a mix of ASCII lower and upper alphas.
+    $attr_name = "[a-zA-Z]+";
+    // Allow anything in attributes values, which are between double quotes.
+    $double_quoted_value = '"[\s\w]*"';
+    $space = "\s*";
+    $attr = $attr_name . "=" . $double_quoted_value . $space;
+    // The pattern must match the entire input's value, rather than matching a
+    // substring - as if a ^(?: were implied at the start of the pattern and )$
+    // at the end.
+    return $space . "(" . $attr . ")*";
+  }
+
   /**
    * Convert a string to an attribute mapping.
    */
diff --git a/src/PropTypeInterface.php b/src/PropTypeInterface.php
index 56bd2cfb8..76db6c5a7 100644
--- a/src/PropTypeInterface.php
+++ b/src/PropTypeInterface.php
@@ -44,11 +44,22 @@ interface PropTypeInterface extends WithJsonSchemaInterface, PluginInspectionInt
   public function getSummary(array $definition): array;
 
   /**
-   * Normalize the prop type value.
+   * Normalize the prop type value before validation.
    *
    * @return mixed
-   *   The normalized prop type value.
+   *   The JSON schema valid prop type value.
    */
   public static function normalize(mixed $value): mixed;
 
+  /**
+   * Preprocess the prop type value before the rendering.
+   *
+   * Called after the validation, before being sent to the template, in order to
+   * ease the work of template owners.
+   *
+   * @return mixed
+   *   The processed prop type value.
+   */
+  public static function preprocess(mixed $value): mixed;
+
 }
diff --git a/src/PropTypePluginBase.php b/src/PropTypePluginBase.php
index c89dca2af..a28f610a6 100644
--- a/src/PropTypePluginBase.php
+++ b/src/PropTypePluginBase.php
@@ -71,4 +71,11 @@ abstract class PropTypePluginBase extends PluginBase implements PropTypeInterfac
     return $value;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public static function preprocess(mixed $value): mixed {
+    return $value;
+  }
+
 }
diff --git a/src/Template/ComponentNodeVisitor.php b/src/Template/ComponentNodeVisitor.php
new file mode 100755
index 000000000..955520ffc
--- /dev/null
+++ b/src/Template/ComponentNodeVisitor.php
@@ -0,0 +1,146 @@
+<?php
+
+namespace Drupal\ui_patterns\Template;
+
+use Drupal\Core\Plugin\Component;
+use Drupal\Core\Render\Component\Exception\ComponentNotFoundException;
+use Drupal\Core\Template\ComponentNodeVisitor as CoreComponentNodeVisitor;
+use Drupal\Core\Theme\ComponentPluginManager;
+use Twig\Environment;
+use Twig\Node\Expression\ConstantExpression;
+use Twig\Node\Expression\FunctionExpression;
+use Twig\Node\ModuleNode;
+use Twig\Node\Node;
+use Twig\Node\PrintNode;
+use Twig\NodeVisitor\NodeVisitorInterface;
+
+/**
+ * Provides a ComponentNodeVisitor to change the generated parse-tree.
+ *
+ * @internal
+ */
+class ComponentNodeVisitor implements NodeVisitorInterface {
+
+  /**
+   * Node name: expr.
+   */
+  const NODE_NAME_EXPR = 'expr';
+
+  /**
+   * The component plugin manager.
+   */
+  protected ComponentPluginManager $componentManager;
+
+  /**
+   * Constructs a new ComponentNodeVisitor object.
+   *
+   * @param \Drupal\Core\Theme\ComponentPluginManager $component_plugin_manager
+   *   The component plugin manager.
+   */
+  public function __construct(ComponentPluginManager $component_plugin_manager) {
+    $this->componentManager = $component_plugin_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enterNode(Node $node, Environment $env): Node {
+    return $node;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function leaveNode(Node $node, Environment $env): ?Node {
+    if (!$node instanceof ModuleNode) {
+      return $node;
+    }
+    $component = $this->getComponent($node);
+    if (!($component instanceof Component)) {
+      return $node;
+    }
+    $line = $node->getTemplateLine();
+    $function = $this->buildPreprocessPropsFunction($line, $component);
+    $node = $this->injectFunction($node, $function);
+    return $node;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPriority(): int {
+    $priority = &drupal_static(__METHOD__);
+    if (!isset($priority)) {
+      $original_node_visitor = new CoreComponentNodeVisitor($this->componentManager);
+      // Ensure that this component node visitor's priority is higher than
+      // core's node visitor class for components, because this class has to run
+      // core's class.
+      $priority = $original_node_visitor->getPriority() + 1;
+    }
+    return is_numeric($priority) ? (int) $priority : 0;
+  }
+
+  /**
+   * Finds the SDC for the current module node.
+   *
+   * A duplicate of \Drupal\Core\Template\ComponentNodeVisitor::getComponent()
+   *
+   * @param \Twig\Node\Node $node
+   *   The node.
+   *
+   * @return \Drupal\Core\Plugin\Component|null
+   *   The component, if any.
+   */
+  protected function getComponent(Node $node): ?Component {
+    $component_id = $node->getTemplateName();
+    if (!preg_match('/^[a-z]([a-zA-Z0-9_-]*[a-zA-Z0-9])*:[a-z]([a-zA-Z0-9_-]*[a-zA-Z0-9])*$/', $component_id)) {
+      return NULL;
+    }
+    try {
+      return $this->componentManager->find($component_id);
+    }
+    catch (ComponentNotFoundException $e) {
+      return NULL;
+    }
+  }
+
+  /**
+   * Build the _ui_patterns_preprocess_props Twig function.
+   *
+   * @param int $line
+   *   The line .
+   * @param \Drupal\Core\Plugin\Component $component
+   *   The component.
+   *
+   * @return \Twig\Node\Node
+   *   The Twig function.
+   */
+  protected function buildPreprocessPropsFunction(int $line, Component $component): Node {
+    $component_id = $component->getPluginId();
+    $function_parameter = new ConstantExpression($component_id, $line);
+    $function_parameters_node = new Node([$function_parameter]);
+    $function = new FunctionExpression('_ui_patterns_preprocess_props', $function_parameters_node, $line);
+    return new PrintNode($function, $line);
+  }
+
+  /**
+   * Injects custom Twig nodes into given node as child nodes.
+   *
+   * The function will be injected direct after  validate_component_props
+   * function already injected by SDC's ComponentNodeVisitor.
+   *
+   * @param \Twig\Node\Node $node
+   *   The node where we will inject the function in.
+   * @param \Twig\Node\Node $function
+   *   The Twig function.
+   *
+   * @return \Twig\Node\Node
+   *   The node with the function inserted.
+   */
+  protected function injectFunction(Node $node, Node $function): Node {
+    $insertion = new Node([$node->getNode('display_start'), $function]);
+    $node->setNode('display_start', $insertion);
+    return $node;
+  }
+
+}
diff --git a/src/Template/TwigExtension.php b/src/Template/TwigExtension.php
index f9e7e4801..d193e8ab0 100644
--- a/src/Template/TwigExtension.php
+++ b/src/Template/TwigExtension.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\ui_patterns\Template;
 
+use Drupal\ui_patterns\ComponentPluginManager;
 use Twig\Extension\AbstractExtension;
 use Twig\TwigFilter;
 use Twig\TwigFunction;
@@ -17,6 +18,16 @@ class TwigExtension extends AbstractExtension {
 
   use AttributesFilterTrait;
 
+  /**
+   * Creates TwigExtension.
+   *
+   * @param \Drupal\ui_patterns\ComponentPluginManager $componentManager
+   *   The component plugin manager.
+   */
+  public function __construct(
+    protected ComponentPluginManager $componentManager,
+  ) {}
+
   /**
    * {@inheritdoc}
    */
@@ -24,15 +35,22 @@ class TwigExtension extends AbstractExtension {
     return 'ui_patterns';
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getNodeVisitors(): array {
+    return [
+      new ComponentNodeVisitor($this->componentManager),
+    ];
+  }
+
   /**
    * {@inheritdoc}
    */
   public function getFunctions() {
     return [
-      new TwigFunction('component', [
-        $this,
-        'renderComponent',
-      ]),
+      new TwigFunction('component', [$this, 'renderComponent']),
+      new TwigFunction('_ui_patterns_preprocess_props', [$this, 'preprocessProps'], ['needs_context' => TRUE]),
     ];
   }
 
@@ -70,4 +88,31 @@ class TwigExtension extends AbstractExtension {
     ];
   }
 
+  /**
+   * Preprocess props.
+   *
+   * This function must not be used by the templates authors. In a perfect
+   * world, it would not be necessary to set such a function. We did that to be
+   * compatible with SDC's ComponentNodeVisitor, in order to execute props
+   * preprocessing after SDC's validate_component_props Twig function.
+   *
+   * @param array $context
+   *   The context provided to the component.
+   * @param string $component_id
+   *   The component ID.
+   *
+   * @throws \Drupal\Core\Render\Component\Exception\InvalidComponentException
+   */
+  public function preprocessProps(array &$context, string $component_id): void {
+    $component = $this->componentManager->find($component_id);
+    $props = $component->metadata->schema['properties'] ?? [];
+    foreach ($context as $variable => $value) {
+      if (!isset($props[$variable])) {
+        continue;
+      }
+      $prop_type = $props[$variable]['ui_patterns']['type_definition'];
+      $context[$variable] = $prop_type->preprocess($value);
+    }
+  }
+
 }
diff --git a/tests/src/Unit/PropTypeNormalization/LinksPropTypeNormalizationTest.php b/tests/src/Unit/PropTypeNormalization/LinksPropTypeNormalizationTest.php
index 1d2e3f3f2..c1d73f57d 100644
--- a/tests/src/Unit/PropTypeNormalization/LinksPropTypeNormalizationTest.php
+++ b/tests/src/Unit/PropTypeNormalization/LinksPropTypeNormalizationTest.php
@@ -110,9 +110,9 @@ final class LinksPropTypeNormalizationTest extends UnitTestCase {
       [
         "title" => "With a relative URL",
         "url" => "/foo/bar",
-        "attributes" => new Attribute([
+        "attributes" => [
           "foo" => "bar",
-        ]),
+        ],
       ],
       [
         "title" => "With empty attributes",
@@ -183,12 +183,10 @@ final class LinksPropTypeNormalizationTest extends UnitTestCase {
       [
         "title" => "‹‹",
         "url" => "/articles?page=0",
-        "attributes" => new Attribute(),
       ],
       [
         "title" => "››",
         "url" => "/articles?page=2",
-        "attributes" => new Attribute(),
       ],
     ];
     return [
@@ -223,26 +221,23 @@ final class LinksPropTypeNormalizationTest extends UnitTestCase {
     ];
     $expected = [
       [
-        "title" => 0,
+        "title" => '0',
         "url" => "?page=0",
-        "attributes" => new Attribute(),
       ],
       [
-        "title" => 1,
+        "title" => '1',
         "url" => "?page=1",
-        "attributes" => new Attribute([
+        "attributes" => [
           "aria-current" => "page",
-        ]),
+        ],
       ],
       [
-        "title" => 2,
+        "title" => '2',
         "url" => "?page=2",
-        "attributes" => new Attribute(),
       ],
       [
-        "title" => 3,
+        "title" => '3',
         "url" => "?page=3",
-        "attributes" => new Attribute(),
       ],
     ];
     return [
@@ -279,22 +274,18 @@ final class LinksPropTypeNormalizationTest extends UnitTestCase {
     ];
     $expected = [
       [
-        "attributes" => new Attribute(),
         "title" => "« First",
         "url" => "?page=0",
       ],
       [
-        "attributes" => new Attribute(),
         "title" => "‹‹",
         "url" => "?page=0",
       ],
       [
-        "attributes" => new Attribute(),
         "title" => "››",
         "url" => "?page=2",
       ],
       [
-        "attributes" => new Attribute(),
         "title" => "Last »",
         "url" => "?page=3",
       ],
@@ -319,7 +310,7 @@ final class LinksPropTypeNormalizationTest extends UnitTestCase {
         "is_expanded" => FALSE,
         "is_collapsed" => FALSE,
         "in_active_trail" => FALSE,
-        "attributes" => new Attribute(),
+        "attributes" => [],
         "title" => "My account",
         "url" => Url::fromUserInput("/user", ["set_active_class" => TRUE]),
         "below" => [],
@@ -328,7 +319,7 @@ final class LinksPropTypeNormalizationTest extends UnitTestCase {
         "is_expanded" => FALSE,
         "is_collapsed" => FALSE,
         "in_active_trail" => FALSE,
-        "attributes" => new Attribute(),
+        "attributes" => [],
         "title" => "Log out",
         "url" => Url::fromUserInput("/user/logout", ["set_active_class" => TRUE]),
         "below" => [],
@@ -359,7 +350,6 @@ final class LinksPropTypeNormalizationTest extends UnitTestCase {
     $value = [
       [
         "href" => "?page=0",
-        "attributes" => new Attribute(),
         "link_attributes" => new Attribute([
           'class' => [
             'display-flex',
@@ -371,16 +361,15 @@ final class LinksPropTypeNormalizationTest extends UnitTestCase {
     ];
     $expected = [
       [
-        "title" => 0,
+        "title" => '0',
         "url" => "?page=0",
-        "attributes" => new Attribute(),
-        "link_attributes" => new Attribute([
+        "link_attributes" => [
           'class' => [
             'display-flex',
             'flex-align-center',
             'flex-no-wrap',
           ],
-        ]),
+        ],
       ],
     ];
     return [
diff --git a/ui_patterns.services.yml b/ui_patterns.services.yml
index 1a015ea00..b230c8bd6 100644
--- a/ui_patterns.services.yml
+++ b/ui_patterns.services.yml
@@ -72,6 +72,8 @@ services:
     class: Drupal\ui_patterns\Template\TwigExtension
     tags:
       - { name: twig.extension }
+    arguments:
+      - "@plugin.manager.sdc"
 
   ui_patterns.sample_entity_generator:
     class: Drupal\ui_patterns\Entity\SampleEntityGenerator
-- 
GitLab