Commit 97033f52 authored by Ben Mullins's avatar Ben Mullins
Browse files

Issue #3228334 by Wim Leers, larowlan, lauriii, DieterHolvoet,...

Issue #3228334 by Wim Leers, larowlan, lauriii, DieterHolvoet, beatrizrodrigues: Refactor HTMLRestrictionsUtilities to a HtmlRestrictions value object
parent 5ba501c3
Loading
Loading
Loading
Loading
+852 −0

File added.

Preview size limit exceeded, changes collapsed.

+0 −284
Original line number Diff line number Diff line
<?php

declare(strict_types = 1);

namespace Drupal\ckeditor5;

use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Utility\DiffArray;
use Drupal\Component\Utility\Html;
use Masterminds\HTML5\Elements;

/**
 * Utilities for interacting with HTML restrictions.
 *
 * @internal
 *
 * @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions()
 */
final class HTMLRestrictionsUtilities {

  /**
   * Wildcard types, and the methods that return tags the wildcard represents.
   *
   * @var string[]
   */
  private const WILDCARD_ELEMENT_METHODS = [
    '$block' => 'getBlockElementList',
  ];

  /**
   * Formats HTML elements for display.
   *
   * @param array $elements
   *   List of elements to format. The structure is the same as the allowed tags
   *   array documented in FilterInterface::getHTMLRestrictions().
   *
   * @return string[]
   *   A formatted list; a string representation of the given HTML elements.
   *
   * @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions()
   */
  public static function toReadableElements(array $elements): array {
    $readable = [];
    foreach ($elements as $tag => $attributes) {
      $attribute_string = '';
      if (is_array($attributes)) {
        foreach ($attributes as $attribute_name => $attribute_values) {
          if (is_array($attribute_values)) {
            $attribute_values_string = implode(' ', array_keys($attribute_values));
            $attribute_string .= "$attribute_name=\"$attribute_values_string\" ";
          }
          else {
            $attribute_string .= "$attribute_name ";
          }
        }
      }
      $joined = '<' . $tag . (!empty($attribute_string) ? ' ' . trim($attribute_string) : '') . '>';
      array_push($readable, $joined);
    }
    assert(Inspector::assertAllStrings($readable));
    return $readable;
  }

  /**
   * Parses a HTML restrictions string with >=1 tags in an array of single tags.
   *
   * @param string $elements_string
   *   A HTML restrictions string.
   *
   * @return string[]
   *   A list of strings, with a HTML tag and potentially attributes in each.
   */
  public static function allowedElementsStringToPluginElementsArray(string $elements_string): array {
    $html_restrictions = static::allowedElementsStringToHtmlFilterArray($elements_string);
    return static::toReadableElements($html_restrictions);
  }

  /**
   * Parses an HTML string into an array structured as expected by filter_html.
   *
   * @param string $elements_string
   *   A string of HTML tags, potentially with attributes.
   *
   * @return array
   *   An elements array. The structure is the same as the allowed tags array
   *   documented in FilterInterface::getHTMLRestrictions().
   *
   * @see \Drupal\ckeditor5\HTMLRestrictionsUtilities::WILDCARD_ELEMENT_METHODS
   *   Each key in this array represents a valid wildcard tag.
   *
   * @see \Drupal\filter\Plugin\Filter\FilterHtml
   * @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions()
   */
  public static function allowedElementsStringToHtmlFilterArray(string $elements_string): array {
    preg_match('/<(\$[A-Z,a-z]*)/', $elements_string, $wildcard_matches);

    $wildcard = NULL;
    if (!empty($wildcard_matches)) {
      $wildcard = $wildcard_matches[1];
      assert(substr($wildcard, 0, 1) === '$', 'Wildcard tags must begin with "$"');
      $elements_string = str_replace($wildcard, 'WILDCARD', $elements_string);
    }

    $elements = [];
    $body_child_nodes = Html::load(str_replace('>', ' />', $elements_string))->getElementsByTagName('body')->item(0)->childNodes;

    foreach ($body_child_nodes as $node) {
      if ($node->nodeType !== XML_ELEMENT_NODE) {
        // Skip the empty text nodes inside tags.
        continue;
      }

      $tag = $wildcard ?? $node->tagName;
      if ($node->hasAttributes()) {
        foreach ($node->attributes as $attribute_name => $attribute) {
          $value = empty($attribute->value) ? TRUE : explode(' ', $attribute->value);
          self::addAllowedAttributeToElements($elements, $tag, $attribute_name, $value);
        }
      }
      else {
        if (!isset($elements[$tag])) {
          $elements[$tag] = FALSE;
        }
      }
    }
    return $elements;
  }

  /**
   * Cleans unwanted artifacts from "allowed HTML" arrays.
   *
   * @param array $elements
   *   An array of allowed elements. The structure is the same as the allowed
   *   tags array documented in FilterInterface::getHTMLRestrictions().
   *
   * @return array
   *   The array without unwanted artifacts.
   *
   * @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions()
   */
  public static function cleanAllowedHtmlArray(array $elements): array {
    // When recursively merging elements arrays, unkeyed boolean values can
    // appear in attribute config arrays. This removes them.
    foreach ($elements as $tag => $tag_config) {
      if (is_array($tag_config)) {
        $elements[$tag] = array_filter($tag_config);
      }
    }
    return $elements;
  }

  /**
   * Adds allowed attributes to the elements array.
   *
   * @param array $elements
   *   The elements array. The structure is the same as the allowed tags array
   *   documented in FilterInterface::getHTMLRestrictions().
   * @param string $tag
   *   The tag having its attributes configured.
   * @param string $attribute
   *   The attribute being configured.
   * @param array|true $value
   *   The attribute config value.
   *
   * @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions()
   */
  public static function addAllowedAttributeToElements(array &$elements, string $tag, string $attribute, $value): void {
    if (isset($elements[$tag][$attribute]) && $elements[$tag][$attribute] === TRUE) {
      // There's nothing to change as the tag/attribute combination is already
      // set to allow all.
      return;
    }

    if (isset($elements[$tag]) && $elements[$tag] === FALSE) {
      // If the tag is already allowed with no attributes then the value will be
      // FALSE. We need to convert the value to an empty array so that attribute
      // configuration can be added.
      $elements[$tag] = [];
    }

    if ($value === TRUE) {
      $elements[$tag][$attribute] = TRUE;
    }
    else {
      foreach ($value as $attribute_value) {
        $elements[$tag][$attribute][$attribute_value] = TRUE;
      }
    }
  }

  /**
   * Compares two HTML restrictions.
   *
   * The structure of the arrays is the same as the allowed tags array
   * documented in FilterInterface::getHTMLRestrictions().
   *
   * @param array $elements_array_1
   *   The array to compare from.
   * @param array $elements_array_2
   *   The array to compare to.
   *
   * @return array
   *   Returns an array with all the values in $elements_array_1 that are not
   *   present in $elements_array_1, including values that are FALSE
   *
   * @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions()
   */
  public static function diffAllowedElements(array $elements_array_1, array $elements_array_2): array {
    return array_filter(
      DiffArray::diffAssocRecursive($elements_array_1, $elements_array_2),
      // DiffArray::diffAssocRecursive() does not know the semantics of the
      // HTML restrictions array: unaware that `TAG => FALSE` is a subset of
      // `TAG => foo` and that in turn is a subset of `TAG => TRUE`.
      // @see \Drupal\filter\Entity\FilterFormat::getHtmlRestrictions()
      function ($value, string $tag) use ($elements_array_2) {
        return $value !== FALSE || !array_key_exists($tag, $elements_array_2);
      },
      ARRAY_FILTER_USE_BOTH
    );
  }

  /**
   * Parses a HTML restrictions string into htmlSupport plugin config structure.
   *
   * @param string $elements_string
   *   A HTML restrictions string.
   *
   * @return string[]
   *   An array of allowed elements, structured in the manner expected by the
   *   CKEditor 5 htmlSupport plugin constructor.
   *
   * @see https://ckeditor5.github.io/docs/nightly/ckeditor5/latest/features/general-html-support.html#configuration
   */
  public static function allowedElementsStringToHtmlSupportConfig(string $elements_string): array {
    $html_restrictions = static::allowedElementsStringToHtmlFilterArray($elements_string);
    $allowed = [];
    foreach ($html_restrictions as $tag => $attributes) {
      $to_allow['name'] = $tag;
      assert($attributes === FALSE || is_array($attributes));
      if (is_array($attributes)) {
        foreach ($attributes as $name => $value) {
          assert($value === TRUE || Inspector::assertAllStrings($value));
          $to_allow['attributes'][$name] = $value;
        }
      }
      $allowed[] = $to_allow;
    }

    return $allowed;
  }

  /**
   * Gets a list of block level elements.
   *
   * @return array
   *   An array of block level element tags.
   */
  private static function getBlockElementList(): array {
    return array_filter(array_keys(Elements::$html5), function (string $element) {
      return Elements::isA($element, Elements::BLOCK_TAG);
    });
  }

  /**
   * Returns the tags that match the provided wildcard.
   *
   * A wildcard tag in element config is a way of representing multiple tags
   * with a single item, such as `<$block>` to represent all block tags. Each
   * wildcard should have a corresponding callback method listed in
   * WILDCARD_ELEMENT_METHODS that returns the set of tags represented by the
   * wildcard.
   *
   * @param string $wildcard
   *   The wildcard that represents multiple tags.
   *
   * @return array
   *   An array of HTML tags.
   */
  public static function getWildcardTags(string $wildcard):array {
    $wildcard_element_method = self::WILDCARD_ELEMENT_METHODS[$wildcard];
    return call_user_func([self::class, $wildcard_element_method]);
  }

}
+4 −5
Original line number Diff line number Diff line
@@ -4,7 +4,7 @@

namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;

use Drupal\ckeditor5\HTMLRestrictionsUtilities;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableInterface;
@@ -43,7 +43,7 @@ public function validateConfigurationForm(array &$form, FormStateInterface $form
    // Match the config schema structure at ckeditor5.plugin.ckeditor5_heading.
    $form_value = $form_state->getValue('allowed_tags');
    if (!is_array($form_value)) {
      $config_value = HTMLRestrictionsUtilities::allowedElementsStringToPluginElementsArray($form_value);
      $config_value = HTMLRestrictions::fromString($form_value)->toCKEditor5ElementsArray();
      $form_state->setValue('allowed_tags', $config_value);
    }
  }
@@ -75,11 +75,10 @@ public function getElementsSubset(): array {
   * {@inheritdoc}
   */
  public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
    $allowed = HTMLRestrictionsUtilities::allowedElementsStringToHtmlSupportConfig(implode('', $this->configuration['allowed_tags']));

    $restrictions = HTMLRestrictions::fromString(implode(' ', $this->configuration['allowed_tags']));
    return [
      'htmlSupport' => [
        'allow' => $allowed,
        'allow' => $restrictions->toGeneralHtmlSupportConfig(),
      ],
    ];
  }
+4 −4
Original line number Diff line number Diff line
@@ -4,7 +4,7 @@

namespace Drupal\ckeditor5\Plugin;

use Drupal\ckeditor5\HTMLRestrictionsUtilities;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Plugin\Definition\PluginDefinition;
use Drupal\Component\Plugin\Definition\PluginDefinitionInterface;
@@ -140,11 +140,11 @@ private function validateDrupalAspects(string $id, array $definition): void {
        if ($definition['id'] === 'ckeditor5_sourceEditing') {
          continue;
        }
        $parsed_elements = HTMLRestrictionsUtilities::allowedElementsStringToPluginElementsArray($element);
        if (count($parsed_elements) === 0) {
        $parsed = HTMLRestrictions::fromString($element);
        if ($parsed->isEmpty()) {
          throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a value at "drupal.elements.%d" that is not an HTML tag with optional attributes: "%s". Expected structure: "<tag allowedAttribute="allowedValue1 allowedValue2">".', $id, $index, $element));
        }
        elseif (count($parsed_elements) > 1) {
        if (count($parsed->getAllowedElements()) > 1) {
          throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a value at "drupal.elements.%d": multiple tags listed, should be one: "%s".', $id, $index, $element));
        }
      }
+6 −30
Original line number Diff line number Diff line
@@ -5,7 +5,7 @@
namespace Drupal\ckeditor5\Plugin;

use Drupal\ckeditor5\Annotation\CKEditor5Plugin;
use Drupal\ckeditor5\HTMLRestrictionsUtilities;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\Component\Annotation\Plugin\Discovery\AnnotationBridgeDecorator;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Utility\NestedArray;
@@ -263,13 +263,12 @@ public function getCKEditor5PluginConfig(EditorInterface $editor): array {
  /**
   * {@inheritdoc}
   */
  public function getProvidedElements(array $plugin_ids = [], EditorInterface $editor = NULL, bool $retain_wildcard = FALSE): array {
  public function getProvidedElements(array $plugin_ids = [], EditorInterface $editor = NULL): array {
    $plugins = $this->getDefinitions();
    if (!empty($plugin_ids)) {
      $plugins = array_intersect_key($plugins, array_flip($plugin_ids));
    }
    $elements = [];
    $processed_elements = [];
    $elements = HTMLRestrictions::emptySet();

    foreach ($plugins as $id => $definition) {
      // Some CKEditor 5 plugins only provide functionality, not additional
@@ -308,35 +307,12 @@ public function getProvidedElements(array $plugin_ids = [], EditorInterface $edi
      }
      assert(Inspector::assertAllStrings($defined_elements));
      foreach ($defined_elements as $element) {
        if (in_array($element, $processed_elements)) {
          continue;
        }
        $processed_elements[] = $element;
        $additional_elements = HTMLRestrictionsUtilities::allowedElementsStringToHtmlFilterArray($element);
        $elements = array_merge_recursive($elements, $additional_elements);
      }
    }

    foreach ($elements as $tag_name => $tag_config) {
      if (substr($tag_name, 0, 1) === '$') {
        $wildcard_tags = HTMLRestrictionsUtilities::getWildcardTags($tag_name);
        foreach ($wildcard_tags as $wildcard_tag) {
          if (isset($elements[$wildcard_tag])) {
            foreach ($tag_config as $attribute_name => $attribute_value) {
              if (is_array($attribute_value)) {
                $attribute_value = array_keys($attribute_value);
              }
              HTMLRestrictionsUtilities::addAllowedAttributeToElements($elements, $wildcard_tag, $attribute_name, $attribute_value);
            }
          }
        }
        if (!$retain_wildcard) {
          unset($elements[$tag_name]);
        }
        $additional_elements = HTMLRestrictions::fromString($element);
        $elements = $elements->merge($additional_elements);
      }
    }

    return HTMLRestrictionsUtilities::cleanAllowedHtmlArray($elements);
    return $elements->getAllowedElements();
  }

  /**
Loading