Unverified Commit f6058b86 authored by Lauri Timmanee's avatar Lauri Timmanee
Browse files

Issue #3231334 by Wim Leers, bnjmnm: Global attributes (<* lang> and <*...

Issue #3231334 by Wim Leers, bnjmnm: Global attributes (<* lang> and <* dir="ltr rtl">): validation + support (fix data loss)

(cherry picked from commit 464291fb)
parent c226816f
Loading
Loading
Loading
Loading
+49 −1
Original line number Diff line number Diff line
@@ -93,6 +93,52 @@ ckeditor5_wildcardHtmlSupport:
    # @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface::getEnabledDefinitions()
    conditions: []

# https://html.spec.whatwg.org/multipage/dom.html#attr-dir
ckeditor5_globalAttributeDir:
  ckeditor5:
    plugins: [htmlSupport.GeneralHtmlSupport]
    config:
      htmlSupport:
        allow:
          -
            # @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\GlobalAttribute::getDynamicPluginConfig()
            name: ~
            attributes:
              - key: dir
                value:
                  regexp:
                    pattern: /^(ltr|rtl)$/
  drupal:
    label: Global `dir` attribute
    class: \Drupal\ckeditor5\Plugin\CKEditor5Plugin\GlobalAttribute
    # @see \Drupal\filter\Plugin\Filter\FilterHtml::getHTMLRestrictions()
    elements:
      - <* dir="ltr rtl">
    library: core/ckeditor5.htmlSupport
    conditions:
      filter: filter_html

# https://html.spec.whatwg.org/multipage/dom.html#attr-lang
ckeditor5_globalAttributeLang:
  ckeditor5:
    plugins: [htmlSupport.GeneralHtmlSupport]
    config:
      htmlSupport:
        allow:
          -
            # @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\GlobalAttribute::getDynamicPluginConfig()
            name: ~
            attributes: lang
  drupal:
    label: Global `lang` attribute
    class: \Drupal\ckeditor5\Plugin\CKEditor5Plugin\GlobalAttribute
    # @see \Drupal\filter\Plugin\Filter\FilterHtml::getHTMLRestrictions()
    elements:
      - <* lang>
    library: core/ckeditor5.htmlSupport
    conditions:
      filter: filter_html

ckeditor5_specialCharacters:
  ckeditor5:
    plugins:
@@ -117,7 +163,9 @@ ckeditor5_sourceEditing:
    class: \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing
    # This is the only CKEditor 5 plugin allowed to generate a superset of elements.
    # @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing::getElementsSubset()
    elements: ['<*>']
    # @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::validateDrupalAspects()
    # @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getProvidedElements()
    elements: []
    library: core/ckeditor5.sourceEditing
    admin_library: ckeditor5/admin.sourceEditing
    toolbar_items:
+105 −15
Original line number Diff line number Diff line
@@ -38,7 +38,7 @@
 * @see ::WILDCARD_ELEMENT_METHODS
 *
 * NOTE: Currently only supports the 'allowed' portion.
 * @todo Add support for "forbidden" tags in https://www.drupal.org/project/drupal/issues/3231334
 * @todo Add support for "forbidden" tags in https://www.drupal.org/project/drupal/issues/3231336
 *
 * @internal
 */
@@ -95,7 +95,7 @@ public function __construct(array $elements) {
   * - Is a string
   * - Does not contain leading or trailing whitespace
   * - Is a tag name, not a tag, e.g. `div` not `<div>`
   * - Is a valid HTML tag name.
   * - Is a valid HTML tag name (or the global attribute `*` tag).
   *
   * @param array $elements
   *   The allowed elements.
@@ -116,6 +116,13 @@ private static function validateAllowedRestrictionsPhase1(array $elements): void
      if (self::isWildcardTag($html_tag_name)) {
        continue;
      }
      // Special case: the global attribute `*` HTML tag.
      // @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
      // @see validateAllowedRestrictionsPhase2()
      // @see validateAllowedRestrictionsPhase4()
      if ($html_tag_name === '*') {
        continue;
      }
      // HTML elements must have a valid tag name.
      // @see https://html.spec.whatwg.org/multipage/syntax.html#syntax-tag-name
      // @see https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
@@ -135,6 +142,17 @@ private static function validateAllowedRestrictionsPhase1(array $elements): void
   */
  private static function validateAllowedRestrictionsPhase2(array $elements): void {
    foreach ($elements as $html_tag_name => $html_tag_restrictions) {
      // The global attribute `*` HTML tag is a special case: it allows
      // specifying specific attributes that are allowed on all tags (f.e.
      // `lang`) or disallowed on all tags (f.e. `style`) as translations and
      // security are concerns orthogonal to the configured HTML restrictions
      // of a text format.
      // @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
      // @see validateAllowedRestrictionsPhase4()
      if ($html_tag_name === '*' && !is_array($html_tag_restrictions)) {
        throw new \InvalidArgumentException(sprintf('The value for the special "*" global attribute HTML tag must be an array of attribute restrictions.'));
      }

      // The value must be either a boolean (FALSE means no attributes are
      // allowed, TRUE means all attributes are allowed), or an array of allowed
      // The value must be either:
@@ -201,6 +219,17 @@ private static function validateAllowedRestrictionsPhase4(array $elements): void
        if ($html_tag_attribute_restrictions === TRUE) {
          continue;
        }
        // Special case: the global attribute `*` HTML tag.
        // The global attribute `*` HTML tag is a special case: it allows
        // specifying specific attributes that are allowed on all tags (f.e.
        // `lang`) or disallowed on all tags (f.e. `style`) as translations and
        // security are concerns orthogonal to the configured HTML restrictions
        // of a text format.
        // @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
        // @see validateAllowedRestrictionsPhase2()
        if ($html_tag_name === '*' && $html_tag_attribute_restrictions === FALSE) {
          continue;
        }
        if (!is_array($html_tag_attribute_restrictions)) {
          throw new \InvalidArgumentException(sprintf('The "%s" HTML tag has an attribute restriction "%s" which is neither TRUE nor an array of attribute value restrictions.', $html_tag_name, $html_tag_attribute_name));
        }
@@ -234,14 +263,17 @@ public function isUnrestricted(): bool {
  }

  /**
   * Whether this is the empty set of HTML restrictions.
   * Whether this set of HTML restrictions allows nothing.
   *
   * @return bool
   *
   * @see ::emptySet()
   */
  public function isEmpty(): bool {
    return count($this->elements) === 0;
  public function allowsNothing(): bool {
    return count($this->elements) === 0
      // If there are only forbidden attributes on the global attribute `*` HTML
      // tag, that is equivalent to the set of restrictions being empty.
      || count($this->elements) === 1 && isset($this->elements['*']) && empty(array_filter($this->elements['*']));
  }

  /**
@@ -316,11 +348,6 @@ private static function fromObjectWithHtmlRestrictions(object $object): HTMLRest
    }

    $allowed = $restrictions['allowed'];
    // @todo Validate attributes allowed or forbidden on all elements
    //   https://www.drupal.org/project/ckeditor5/issues/3231334.
    if (isset($allowed['*'])) {
      unset($allowed['*']);
    }

    return new self($allowed);
  }
@@ -338,13 +365,16 @@ private static function fromObjectWithHtmlRestrictions(object $object): HTMLRest
   */
  public static function fromString(string $elements_string): HTMLRestrictions {
    // Preprocess wildcard tags: convert `<$text-container>` to
    // `<__preprocessed-wildcard-text-container__>`.
    // `<__preprocessed-wildcard-text-container__>` and `<*>` to
    // `<__preprocessed-global-attribute__>`.
    // Note: unknown wildcard tags will trigger a validation error in
    // ::validateAllowedRestrictionsPhase1().
    $replaced_wildcard_tags = [];
    $elements_string = preg_replace_callback('/<(\$[a-z][0-9a-z\-]*)/', function ($matches) use (&$replaced_wildcard_tags) {
    $elements_string = preg_replace_callback('/<(\$[a-z][0-9a-z\-]*|\*)/', function ($matches) use (&$replaced_wildcard_tags) {
      $wildcard_tag_name = $matches[1];
      $replacement = sprintf("__preprocessed-wildcard-%s__", substr($wildcard_tag_name, 1));
      $replacement = $wildcard_tag_name === '*'
        ? '__preprocessed-global-attribute__'
        : sprintf("__preprocessed-wildcard-%s__", substr($wildcard_tag_name, 1));
      $replaced_wildcard_tags[$replacement] = $wildcard_tag_name;
      return "<$replacement";
    }, $elements_string);
@@ -566,6 +596,16 @@ public function doIntersect(HTMLRestrictions $other): HTMLRestrictions {
          $intersection[$tag][$attr] = $this->elements[$tag][$attr];
          continue;
        }
        // If either allows no attribute values, nor does the intersection.
        if ($this->elements[$tag][$attr] === FALSE || $other->elements[$tag][$attr] === FALSE) {
          // Special case: the global attribute `*` HTML tag.
          // @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
          // @see validateAllowedRestrictionsPhase2()
          // @see validateAllowedRestrictionsPhase4()
          assert($tag === '*');
          $intersection[$tag][$attr] = FALSE;
          continue;
        }
        assert(is_array($this->elements[$tag][$attr]));
        assert(is_array($other->elements[$tag][$attr]));
        $intersection[$tag][$attr] = array_intersect_key($this->elements[$tag][$attr], $other->elements[$tag][$attr]);
@@ -579,6 +619,13 @@ public function doIntersect(HTMLRestrictions $other): HTMLRestrictions {
      // HTML tags must not have an empty array of allowed attributes.
      if ($intersection[$tag] === []) {
        $intersection[$tag] = FALSE;
        // Special case: the global attribute `*` HTML tag.
        // @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
        // @see validateAllowedRestrictionsPhase2()
        // @see validateAllowedRestrictionsPhase4()
        if ($tag === '*') {
          unset($intersection[$tag]);
        }
      }
    }

@@ -688,11 +735,30 @@ public function merge(HTMLRestrictions $other): HTMLRestrictions {
        // If the HTML tag restrictions are arrays for both operands, similar
        // logic needs to be applied to the attribute-level restrictions.
        foreach ($tag_config as $html_tag_attribute_name => $html_tag_attribute_restrictions) {
          if ($html_tag_attribute_restrictions === TRUE) {
          if (is_bool($html_tag_attribute_restrictions)) {
            continue;
          }

          if (array_key_exists(0, $html_tag_attribute_restrictions)) {
            // Special case: the global attribute `*` HTML tag.
            // @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
            // @see validateAllowedRestrictionsPhase2()
            // @see validateAllowedRestrictionsPhase4()
            if ($tag === '*') {
              assert(is_bool($html_tag_attribute_restrictions[0]) || is_bool($html_tag_attribute_restrictions[1]));
              // When both are boolean, pick the most permissive value.
              if (is_bool($html_tag_attribute_restrictions[0]) && isset($html_tag_attribute_restrictions[1]) && is_bool($html_tag_attribute_restrictions[1])) {
                $value = $html_tag_attribute_restrictions[0] || $html_tag_attribute_restrictions[1];
              }
              else {
                $value = is_bool($html_tag_attribute_restrictions[0])
                  ? $html_tag_attribute_restrictions[0]
                  : $html_tag_attribute_restrictions[1];
              }
              $union[$tag][$html_tag_attribute_name] = $value;
              continue;
            }

            // The "twice FALSE" case cannot occur for attributes, because
            // attribute restrictions either have "TRUE" (to indicate any value
            // is allowed for the attribute) or a list of allowed attribute
@@ -955,10 +1021,27 @@ public function toCKEditor5ElementsArray(): array {
            $attribute_string .= "$attribute_name=\"$attribute_values_string\" ";
          }
          else {
            // Special case: the global attribute `*` HTML tag.
            // @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
            // @see validateAllowedRestrictionsPhase2()
            // @see validateAllowedRestrictionsPhase4()
            if ($attribute_values === FALSE) {
              assert($tag === '*');
              continue;
            }
            $attribute_string .= "$attribute_name ";
          }
        }
      }

      // Special case: the global attribute `*` HTML tag.
      // @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
      // @see validateAllowedRestrictionsPhase2()
      // @see validateAllowedRestrictionsPhase4()
      if ($tag === '*' && empty(array_filter($attributes))) {
        continue;
      }

      $joined = '<' . $tag . (!empty($attribute_string) ? ' ' . trim($attribute_string) : '') . '>';
      array_push($readable, $joined);
    }
@@ -980,6 +1063,9 @@ public function toFilterHtmlAllowedTagsString(): string {
    // Resolve wildcard tags, because Drupal's filter_html filter plugin does
    // not support those.
    $concrete = self::resolveWildcards($this);
    // The filter_html plugin does not allow configuring additional globally
    // allowed or disallowed attributes. It uses a hardcoded list.
    $concrete = new HTMLRestrictions(array_diff_key($concrete->getAllowedElements(FALSE), ['*' => NULL]));
    return implode(' ', $concrete->toCKEditor5ElementsArray());
  }

@@ -1015,7 +1101,11 @@ public function toGeneralHtmlSupportConfig(): array {
          if ($name === 'style') {
            continue;
          }
          assert($value === TRUE || Inspector::assertAllStrings($value));
          // Special case: the global attribute `*` HTML tag.
          // @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
          // @see validateAllowedRestrictionsPhase2()
          // @see validateAllowedRestrictionsPhase4()
          assert($value === TRUE || Inspector::assertAllStrings($value) || ($tag === '*' && $value === FALSE));
          // If a single attribute value is allowed, it must be TRUE (see the
          // assertion above). Otherwise, it must be an array of strings (see
          // the assertion above), which lists all allowed attribute values. To
+48 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\ckeditor5\Plugin\CKEditor5Plugin;

use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\editor\EditorInterface;

/**
 * CKEditor 5 Global Attribute for filter_html.
 *
 * Can be used for adding support for any "global attribute". For example:
 * `<* lang>` to allow the `lang` attribute on all supported tags.
 *
 * @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
 *
 * @internal
 *   Plugin classes are internal.
 */
class GlobalAttribute extends CKEditor5PluginDefault {

  /**
   * {@inheritdoc}
   */
  public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array {
    // This plugin is only loaded when filter_html is enabled.
    assert($editor->getFilterFormat()->filters()->has('filter_html'));
    $filter_html = $editor->getFilterFormat()->filters('filter_html');
    $restrictions = HTMLRestrictions::fromFilterPluginInstance($filter_html);

    // Determine which tags are allowed by filter_html, excluding the global
    // attribute `*` HTML tag, because that's what we're expanding this to right
    // now.
    $allowed_elements = $restrictions->getAllowedElements();
    unset($allowed_elements['*']);
    $allowed_tags = array_keys($allowed_elements);

    // Update the static plugin configuration: generate a `name` regular
    // expression to match any of the HTML tags supported by filter_html.
    // @see https://ckeditor.com/docs/ckeditor5/latest/features/general-html-support.html#configuration
    $dynamic_plugin_config = $static_plugin_config;
    $dynamic_plugin_config['htmlSupport']['allow'][0]['name']['regexp']['pattern'] = '/^(' . implode('|', $allowed_tags) . ')$/';
    return $dynamic_plugin_config;
  }

}
+11 −15
Original line number Diff line number Diff line
@@ -122,27 +122,23 @@ private function validateDrupalAspects(string $id, array $definition): void {
    if (!isset($definition['drupal']['elements'])) {
      throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition must contain a "drupal.elements" key.', $id));
    }
    // ckeditor5_sourceEditing is the edge case here: it is the only plugin that
    // is allowed to return a superset. It's a special case because it is
    // through configuring this particular plugin that additional HTML tags can
    // be allowed.
    // The list of tags it supports is generated dynamically. In its default
    // configuration it does support any HTML tags.
    // @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getProvidedElements()
    elseif ($definition['id'] === 'ckeditor5_sourceEditing') {
      assert($definition['drupal']['elements'] === []);
    }
    elseif ($definition['drupal']['elements'] !== FALSE && !(is_array($definition['drupal']['elements']) && !empty($definition['drupal']['elements']) && Inspector::assertAllStrings($definition['drupal']['elements']))) {
      throw new InvalidPluginDefinitionException($id, sprintf('The "%s" CKEditor 5 plugin definition has a "drupal.elements" value that is neither a list of HTML tags/attributes nor false.', $id));
    }
    elseif (is_array($definition['drupal']['elements'])) {
      foreach ($definition['drupal']['elements'] as $index => $element) {
        // ckeditor5_sourceEditing is the edge case here: it is the only plugin
        // that is allowed to return a superset. It's a special case because it
        // is through configuring this particular plugin that additional HTML
        // tags can be allowed.
        // Even though its plugin definition says '<*>' is supported, this is a
        // little lie to convey that this plugin is capable of supporting any
        // HTML tag … but which ones are actually supported depends on the
        // configuration.
        // This also means that without any configuration, it does not support
        // any HTML tags.
        // @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getProvidedElements()
        if ($definition['id'] === 'ckeditor5_sourceEditing') {
          continue;
        }
        $parsed = HTMLRestrictions::fromString($element);
        if ($parsed->isEmpty()) {
        if ($parsed->allowsNothing()) {
          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));
        }
        if (count($parsed->getAllowedElements()) > 1) {
+3 −7
Original line number Diff line number Diff line
@@ -177,7 +177,7 @@ public function getEnabledDefinitions(EditorInterface $editor): array {

    if (!isset($definitions['ckeditor5_arbitraryHtmlSupport'])) {
      $restrictions = new HTMLRestrictions($this->getProvidedElements(array_keys($definitions), $editor, FALSE));
      if ($restrictions->getWildcardSubset()->isEmpty()) {
      if ($restrictions->getWildcardSubset()->allowsNothing()) {
        // This is only reached if arbitrary HTML is not enabled. If wildcard
        // tags (such as $text-container) are present, they need to
        // be resolved via the wildcardHtmlSupport plugin.
@@ -318,12 +318,8 @@ public function getProvidedElements(array $plugin_ids = [], EditorInterface $edi
        // that is allowed to return a superset. It's a special case because it
        // is through configuring this particular plugin that additional HTML
        // tags can be allowed.
        // Even though its plugin definition says '<*>' is supported, this is a
        // little lie to convey that this plugin is capable of supporting any
        // HTML tag … but which ones are actually supported depends on the
        // configuration.
        // This also means that without any configuration, it does not support
        // any HTML tags.
        // The list of tags it supports is generated dynamically. In its default
        // configuration it does support any HTML tags.
        if ($id === 'ckeditor5_sourceEditing') {
          $defined_elements = !isset($editor) ? [] : $this->getPlugin($id, $editor)->getElementsSubset();
        }
Loading