From 94f5d3d1732dc2fac23aceaeec4ec0fd927ff8fb Mon Sep 17 00:00:00 2001
From: Pierre Dureau <31905-pdureau@users.noreply.drupalcode.org>
Date: Tue, 29 Oct 2024 20:42:47 +0000
Subject: [PATCH] Issue #3474822 by pdureau, sea2709, smustgrave,
 just_like_good_vibes: Normalize attributes values

---
 src/Element/ComponentElementAlter.php         |  25 +--
 .../PropType/AttributesPropType.php           | 115 +++++++++++++-
 .../UiPatterns/PropType/LinksPropType.php     |   7 +-
 .../AttributesPropTypeNormalizationTest.php   | 143 ++++++++++++++++++
 4 files changed, 262 insertions(+), 28 deletions(-)
 create mode 100644 tests/src/Unit/PropTypeNormalization/AttributesPropTypeNormalizationTest.php

diff --git a/src/Element/ComponentElementAlter.php b/src/Element/ComponentElementAlter.php
index 063a23814..f4e944e77 100644
--- a/src/Element/ComponentElementAlter.php
+++ b/src/Element/ComponentElementAlter.php
@@ -35,9 +35,12 @@ class ComponentElementAlter implements TrustedCallbackInterface {
   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);
+    // Attributes prop must never be empty, to avoid the processing of SDC's
+    // ComponentsTwigExtension::mergeAdditionalRenderContext() which is adding
+    // an Attribute PHP object before running the validator.
+    $element["#props"]["attributes"]['data-component-id'] = $component->getPluginId();
+    $element = $this->processAttributesRenderProperty($element);
     return $element;
   }
 
@@ -87,24 +90,6 @@ class ComponentElementAlter implements TrustedCallbackInterface {
     return $element;
   }
 
-  /**
-   * 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.
    *
diff --git a/src/Plugin/UiPatterns/PropType/AttributesPropType.php b/src/Plugin/UiPatterns/PropType/AttributesPropType.php
index 01149e6cd..66311460b 100644
--- a/src/Plugin/UiPatterns/PropType/AttributesPropType.php
+++ b/src/Plugin/UiPatterns/PropType/AttributesPropType.php
@@ -4,8 +4,12 @@ declare(strict_types=1);
 
 namespace Drupal\ui_patterns\Plugin\UiPatterns\PropType;
 
+use Drupal\Component\Render\MarkupInterface;
+use Drupal\Core\Render\Element;
+use Drupal\Core\Render\RenderableInterface;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\Template\Attribute;
+use Drupal\Core\Url;
 use Drupal\ui_patterns\Attribute\PropType;
 use Drupal\ui_patterns\PropTypePluginBase;
 
@@ -49,12 +53,119 @@ class AttributesPropType extends PropTypePluginBase {
     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();
+    if (is_array($value)) {
+      // Attribute::createAttributeValue() is already normalizing some stuff:
+      // - 'class' attribute must be a list
+      // - MarkupInterface values must be resolved.
+      $value = (new Attribute($value))->toArray();
+    }
+    elseif (is_a($value, '\Drupal\Core\Template\Attribute')) {
+      // Attribute PHP objects are rendered as strings by SDC ComponentValidator
+      // this is raising an error: "InvalidComponentException: String value
+      // found, but an object is required".
+      $value = $value->toArray();
+    }
+    else {
+      return [];
+    }
+    foreach ($value as $attr => $attr_value) {
+      $value[$attr] = self::normalizeAttrValue($attr_value);
     }
     return $value;
   }
 
+  /**
+   * Normalize attribute value.
+   */
+  protected static function normalizeAttrValue(mixed $value): mixed {
+    if (is_object($value)) {
+      return self::normalizeObject($value);
+    }
+    if (is_array($value) && array_is_list($value)) {
+      return self::normalizeList($value);
+    }
+    if (is_array($value) && !array_is_list($value)) {
+      return self::normalizeMapping($value);
+    }
+    // We don't allow markup in attribute value.
+    return strip_tags((string) $value);
+  }
+
+  /**
+   * Normalize list item.
+   */
+  protected static function normalizeListItem(mixed $value): mixed {
+    if (is_object($value)) {
+      return self::normalizeObject($value);
+    }
+    if (is_array($value) && array_is_list($value)) {
+      // We encode to JSON because we don't know how deep is the nesting.
+      return json_encode($value, 0, 3) ?: "";
+    }
+    if (is_array($value) && !array_is_list($value)) {
+      return self::normalizeRenderArray($value);
+    }
+    // Integer and number are always allowed values.
+    if (is_int($value) || is_float($value)) {
+      return $value;
+    }
+    // We don't allow markup in attribute value.
+    return strip_tags((string) $value);
+  }
+
+  /**
+   * Normalize object attribute value.
+   */
+  protected static function normalizeObject(object $value): array|string {
+    if ($value instanceof Url) {
+      return $value->toString();
+    }
+    if ($value instanceof RenderableInterface) {
+      return static::normalizeRenderArray($value->toRenderable());
+    }
+    if ($value instanceof MarkupInterface) {
+      return (string) $value;
+    }
+    if ($value instanceof \Stringable) {
+      return (string) $value;
+    }
+    // Instead of keeping an unexpected object, we return PHP namespace.
+    // It will be valid and can inform the component user about its mistake.
+    return get_class($value);
+  }
+
+  /**
+   * Normalize list attribute value.
+   */
+  protected static function normalizeList(array $value): array {
+    foreach ($value as $index => $item) {
+      $value[$index] = self::normalizeListItem($item);
+    }
+    return $value;
+  }
+
+  /**
+   * Normalize mapping attribute value.
+   */
+  protected static function normalizeMapping(array $value): array|string {
+    if (!empty(Element::properties($value))) {
+      return static::normalizeRenderArray($value);
+    }
+    return static::normalizeList(array_values($value));
+  }
+
+  /**
+   * Normalize render array.
+   */
+  protected static function normalizeRenderArray(array $value): string {
+    if (!empty(Element::properties($value))) {
+      $markup = (string) \Drupal::service('renderer')->render($value);
+      return strip_tags($markup);
+    }
+    // We encode to JSON because we don't know how deep is the nesting.
+    return json_encode($value, 0, 3) ?: "";
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/src/Plugin/UiPatterns/PropType/LinksPropType.php b/src/Plugin/UiPatterns/PropType/LinksPropType.php
index 58290aa43..9e10bed72 100644
--- a/src/Plugin/UiPatterns/PropType/LinksPropType.php
+++ b/src/Plugin/UiPatterns/PropType/LinksPropType.php
@@ -156,12 +156,7 @@ class LinksPropType extends PropTypePluginBase implements ContainerFactoryPlugin
       unset($item[$property]);
       return $item;
     }
-    // Convert URL objects to strings.
-    foreach ($item[$property] as $attr => $value) {
-      if ($value instanceof Url) {
-        $item[$property][$attr] = $value->toString();
-      }
-    }
+    $item[$property] = AttributesPropType::normalize($item[$property]);
     return $item;
   }
 
diff --git a/tests/src/Unit/PropTypeNormalization/AttributesPropTypeNormalizationTest.php b/tests/src/Unit/PropTypeNormalization/AttributesPropTypeNormalizationTest.php
new file mode 100644
index 000000000..be3b387c0
--- /dev/null
+++ b/tests/src/Unit/PropTypeNormalization/AttributesPropTypeNormalizationTest.php
@@ -0,0 +1,143 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\ui_patterns\Unit;
+
+use Drupal\Tests\UnitTestCase;
+use Drupal\ui_patterns\Plugin\UiPatterns\PropType\AttributesPropType;
+
+/**
+ * @coversDefaultClass \Drupal\ui_patterns\Plugin\UiPatterns\PropType\LinksPropType
+ *
+ * @group ui_patterns
+ */
+final class AttributesPropTypeNormalizationTest extends UnitTestCase {
+
+  /**
+   * @covers ::normalize
+   *
+   * @dataProvider provideNormalizationData
+   */
+  public function testNormalize(array $value, array $expected): void {
+    $normalized = AttributesPropType::normalize($value);
+    self::assertEquals($normalized, $expected);
+  }
+
+  /**
+   * Provide data for testNormalize.
+   */
+  public static function provideNormalizationData(): \Generator {
+    $data = [
+      "Empty value" => [
+        "value" => [],
+        "expected" => [],
+      ],
+      "Standardized primitives, so already OK" => self::standardizedPrimitives(),
+      "Type transformations" => self::typeTransformation(),
+      "List array" => self::listArray(),
+
+    ];
+    foreach ($data as $label => $test) {
+      yield $label => [
+        $test['value'],
+        $test['expected'],
+      ];
+    };
+  }
+
+  /**
+   * Standardized primitives, so already OK.
+   */
+  protected static function standardizedPrimitives() {
+    $value = [
+      "foo" => "bar",
+      "string" => "Lorem ipsum",
+      "array" => [
+        "One",
+        "Two",
+        3,
+      ],
+      "integer" => 4,
+      "float" => 1.4,
+    ];
+    $expected = $value;
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+  /**
+   * Type transformations.
+   */
+  protected static function typeTransformation() {
+    $value = [
+      "true_boolean" => TRUE,
+      "false_boolean" => FALSE,
+      "null_boolean" => NULL,
+      "markup" => "Hello <b>World</b>",
+      "associative_array" => [
+        "Un" => "One",
+        "Deux" => "Two",
+        "Trois" => 3,
+      ],
+      "nested_array" => [
+        "One",
+        [
+          "Two",
+          "Three",
+        ],
+        [
+          "deep" => [
+            "very deep" => ["foo", "bar"],
+          ],
+        ],
+      ],
+    ];
+    $expected = [
+      "true_boolean" => "1",
+      "false_boolean" => "",
+      "null_boolean" => "",
+      "markup" => "Hello World",
+      "associative_array" => [
+        "One",
+        "Two",
+        3,
+      ],
+      "nested_array" => [
+        "One",
+        // JSON encoding because we don't know how deep is the nesting.
+        '["Two","Three"]',
+        '{"deep":{"very deep":["foo","bar"]}}',
+      ],
+    ];
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+  /**
+   * List array.
+   */
+  protected static function listArray() {
+    $value = [
+      "One",
+      "Two",
+      3,
+    ];
+    // This doesn't look like a valid HTML attribute structure, but we rely on
+    // Drupal Attribute object normalization here.
+    $expected = [
+      "0" => "One",
+      "1" => "Two",
+      "2" => 3,
+    ];
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+}
-- 
GitLab