core/modules/ckeditor5/src/HTMLRestrictions.php | 852 ++++++++++++++++++++ .../ckeditor5/src/HTMLRestrictionsUtilities.php | 284 ------- .../src/Plugin/CKEditor5Plugin/SourceEditing.php | 9 +- .../src/Plugin/CKEditor5PluginDefinition.php | 8 +- .../src/Plugin/CKEditor5PluginManager.php | 36 +- .../src/Plugin/CKEditor5PluginManagerInterface.php | 5 +- .../ckeditor5/src/Plugin/Editor/CKEditor5.php | 7 +- ...FundamentalCompatibilityConstraintValidator.php | 62 +- ...urceEditingRedundantTagsConstraintValidator.php | 56 +- .../modules/ckeditor5/src/SmartDefaultSettings.php | 145 +--- .../src/Kernel/CKEditor5PluginManagerTest.php | 6 +- .../tests/src/Kernel/SmartDefaultSettingsTest.php | 24 +- .../ckeditor5/tests/src/Kernel/ValidatorsTest.php | 4 + .../tests/src/Unit/HTMLRestrictionsTest.php | 895 +++++++++++++++++++++ .../filter/src/Plugin/Filter/FilterHtml.php | 7 + 15 files changed, 1883 insertions(+), 517 deletions(-) diff --git a/core/modules/ckeditor5/src/HTMLRestrictions.php b/core/modules/ckeditor5/src/HTMLRestrictions.php new file mode 100644 index 0000000000..9870153281 --- /dev/null +++ b/core/modules/ckeditor5/src/HTMLRestrictions.php @@ -0,0 +1,852 @@ + 'getBlockElementList', + ]; + + /** + * Constructs a set of HTML restrictions. + * + * @param array $elements + * The allowed elements. + * + * @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions() + */ + public function __construct(array $elements) { + self::validateAllowedRestrictionsPhase1($elements); + self::validateAllowedRestrictionsPhase2($elements); + self::validateAllowedRestrictionsPhase3($elements); + self::validateAllowedRestrictionsPhase4($elements); + $this->elements = $elements; + } + + /** + * Validates allowed elements — phase 1: shape of keys. + * + * Confirms each of the top-level array keys: + * - Is a string + * - Does not contain leading or trailing whitespace + * - Is a tag name, not a tag, e.g. `div` not `
` + * - Is a valid HTML tag name. + * + * @param array $elements + * The allowed elements. + * + * @throws \InvalidArgumentException + */ + private static function validateAllowedRestrictionsPhase1(array $elements): void { + if (!is_array($elements) || !Inspector::assertAllStrings(array_keys($elements))) { + throw new \InvalidArgumentException('An array of key-value pairs must be provided, with HTML tag names as keys.'); + } + foreach (array_keys($elements) as $html_tag_name) { + if (trim($html_tag_name) !== $html_tag_name) { + throw new \InvalidArgumentException(sprintf('The "%s" HTML tag contains trailing or leading whitespace.', $html_tag_name)); + } + if ($html_tag_name[0] === '<' || $html_tag_name[-1] === '>') { + throw new \InvalidArgumentException(sprintf('"%s" is not a HTML tag name, it is an actual HTML tag. Omit the angular brackets.', $html_tag_name)); + } + if (self::isWildcardTag($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 + if (!preg_match('/^[a-z][0-9a-z\-]*$/', strtolower($html_tag_name))) { + throw new \InvalidArgumentException(sprintf('"%s" is not a valid HTML tag name.', $html_tag_name)); + } + } + } + + /** + * Validates allowed elements — phase 2: shape of values. + * + * @param array $elements + * The allowed elements. + * + * @throws \InvalidArgumentException + */ + private static function validateAllowedRestrictionsPhase2(array $elements): void { + foreach ($elements as $html_tag_name => $html_tag_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: + // - An array of allowed attribute names OR + // - A boolean (where FALSE means no attributes are allowed, and TRUE + // means all attributes are allowed). + if (is_bool($html_tag_restrictions)) { + continue; + } + if (!is_array($html_tag_restrictions)) { + throw new \InvalidArgumentException(sprintf('The value for the "%s" HTML tag is neither a boolean nor an array of attribute restrictions.', $html_tag_name)); + } + if ($html_tag_restrictions === []) { + throw new \InvalidArgumentException(sprintf('The value for the "%s" HTML tag is an empty array. This is not permitted, specify FALSE instead to indicate no attributes are allowed. Otherwise, list allowed attributes.', $html_tag_name)); + } + } + } + + /** + * Validates allowed elements — phase 3: HTML tag attribute restriction keys. + * + * @param array $elements + * The allowed elements. + * + * @throws \InvalidArgumentException + */ + private static function validateAllowedRestrictionsPhase3(array $elements): void { + foreach ($elements as $html_tag_name => $html_tag_restrictions) { + if (!is_array($html_tag_restrictions)) { + continue; + } + if (!Inspector::assertAllStrings(array_keys($html_tag_restrictions))) { + throw new \InvalidArgumentException(sprintf('The "%s" HTML tag has attribute restrictions, but it is not an array of key-value pairs, with HTML tag attribute names as keys.', $html_tag_name)); + } + + foreach ($html_tag_restrictions as $html_tag_attribute_name => $html_tag_attribute_restrictions) { + if (trim($html_tag_attribute_name) !== $html_tag_attribute_name) { + throw new \InvalidArgumentException(sprintf('The "%s" HTML tag has an attribute restriction "%s" which contains whitespace. Omit the whitespace.', $html_tag_name, $html_tag_attribute_name)); + } + } + } + } + + /** + * Validates allowed elements — phase 4: HTML tag attr restriction values. + * + * @param array $elements + * The allowed elements. + * + * @throws \InvalidArgumentException + */ + private static function validateAllowedRestrictionsPhase4(array $elements): void { + foreach ($elements as $html_tag_name => $html_tag_restrictions) { + if (!is_array($html_tag_restrictions)) { + continue; + } + + foreach ($html_tag_restrictions as $html_tag_attribute_name => $html_tag_attribute_restrictions) { + // The value must be either TRUE (meaning all values for this + // are allowed), or an array of allowed attribute values. + if ($html_tag_attribute_restrictions === TRUE) { + 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)); + } + if ($html_tag_attribute_restrictions === []) { + throw new \InvalidArgumentException(sprintf('The "%s" HTML tag has an attribute restriction "%s" which is set to the empty array. This is not permitted, specify either TRUE to allow all attribute values, or list the attribute value restrictions.', $html_tag_name, $html_tag_attribute_name)); + } + // @codingStandardsIgnoreLine + if (!Inspector::assertAll(function ($v) { return $v === TRUE; }, $html_tag_attribute_restrictions)) { + throw new \InvalidArgumentException(sprintf('The "%s" HTML tag has attribute restriction "%s", but it is not an array of key-value pairs, with HTML tag attribute values as keys and TRUE as values.', $html_tag_name, $html_tag_attribute_name)); + } + } + } + } + + /** + * Creates the empty set of HTML restrictions: nothing is allowed. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + */ + public static function emptySet(): HTMLRestrictions { + return new self([]); + } + + /** + * Whether this is the empty set of HTML restrictions. + * + * @return bool + * + * @see ::emptySet() + */ + public function isEmpty(): bool { + return count($this->elements) === 0; + } + + /** + * Constructs a set of HTML restrictions matching the given text format. + * + * @param \Drupal\filter\Plugin\FilterInterface $filter + * A filter plugin instance to construct a HTML restrictions object for. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + */ + public static function fromFilterPluginInstance(FilterInterface $filter): HTMLRestrictions { + return self::fromObjectWithHtmlRestrictions($filter); + } + + /** + * Constructs a set of HTML restrictions matching the given text format. + * + * @param \Drupal\filter\FilterFormatInterface $text_format + * A text format to construct a HTML restrictions object for. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + */ + public static function fromTextFormat(FilterFormatInterface $text_format): HTMLRestrictions { + return self::fromObjectWithHtmlRestrictions($text_format); + } + + /** + * Constructs a set of HTML restrictions matching the given object. + * + * Note: there is no interface for the ::getHTMLRestrictions() method that + * both text filter plugins and the text format configuration entity type + * implement. To avoid duplicating this logic, this private helper method + * exists: to simplify the two public static methods that each accept one of + * those two interfaces. + * + * @param \Drupal\filter\Plugin\FilterInterface|\Drupal\filter\FilterFormatInterface $object + * A text format or filter plugin instance to construct a HTML restrictions + * object for. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * + * @see ::fromFilterPluginInstance() + * @see ::fromTextFormat() + */ + private static function fromObjectWithHtmlRestrictions(object $object): HTMLRestrictions { + if (!method_exists($object, 'getHTMLRestrictions')) { + throw new \InvalidArgumentException(); + } + + $restrictions = $object->getHTMLRestrictions(); + if (!isset($restrictions['allowed'])) { + // @todo Handle HTML restrictor filters that only set forbidden_tags + // https://www.drupal.org/project/ckeditor5/issues/3231336. + throw new \DomainException('text formats with only filters that forbid tags rather than allowing tags are not yet supported.'); + } + + $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); + } + + /** + * Parses a string of HTML restrictions into a HTMLRestrictions value object. + * + * @param string $elements_string + * A string representing a list of allowed HTML elements. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * + * @see ::toFilterHtmlAllowedTagsString() + * @see ::toCKEditor5ElementsArray() + */ + public static function fromString(string $elements_string): HTMLRestrictions { + // Preprocess wildcard tags: convert `<$block>` to + // `<__preprocessed-wildcard-block__>`. + // 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) { + $wildcard_tag_name = $matches[1]; + $replacement = sprintf("__preprocessed-wildcard-%s__", substr($wildcard_tag_name, 1)); + $replaced_wildcard_tags[$replacement] = $wildcard_tag_name; + return "<$replacement"; + }, $elements_string); + + // Reuse the parsing logic from FilterHtml::getHTMLRestrictions(). + $configuration = ['settings' => ['allowed_html' => $elements_string]]; + $filter = new FilterHtml($configuration, 'filter_html', ['provider' => 'filter']); + $allowed_elements = $filter->getHTMLRestrictions()['allowed']; + // Omit the broad wildcard addition that FilterHtml::getHTMLRestrictions() + // always sets; it is specific to how FilterHTML works and irrelevant here. + unset($allowed_elements['*']); + + // Postprocess tag wildcards: convert `<__preprocessed-wildcard-block__>` to + // `<$block>`. + foreach ($replaced_wildcard_tags as $processed => $original) { + if (isset($allowed_elements[$processed])) { + $allowed_elements[$original] = $allowed_elements[$processed]; + unset($allowed_elements[$processed]); + } + } + + return new self($allowed_elements); + } + + /** + * Computes difference of two HTML restrictions, with wildcard support. + * + * @param \Drupal\ckeditor5\HTMLRestrictions $other + * The HTML restrictions to compare to. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * Returns a new HTML restrictions value object with all the elements that + * are not allowed in $other. + */ + public function diff(HTMLRestrictions $other): HTMLRestrictions { + return self::applyOperation($this, $other, 'doDiff'); + } + + /** + * Computes difference of two HTML restrictions, without wildcard support. + * + * @param \Drupal\ckeditor5\HTMLRestrictions $other + * The HTML restrictions to compare to. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * Returns a new HTML restrictions value object with all the elements that + * are not allowed in $other. + */ + private function doDiff(HTMLRestrictions $other): HTMLRestrictions { + $diff_elements = array_filter( + DiffArray::diffAssocRecursive($this->elements, $other->elements), + // DiffArray::diffAssocRecursive() provides a good start, but additional + // filtering is necessary due to the specific semantics of an HTML + // restrictions array, where: + // - A value of FALSE for a given tag/attribute disallows all + // attributes/ /attribute values for that tag/attribute. + // - An array value for a given tag/attribute provides an array keyed by + // specific attributes/attribute values with boolean values determining + // if they are allowed or not. + // - A value of TRUE for a given tag/attribute permits all attributes/attribute + // values for that tag/attribute. + // @see \Drupal\filter\Entity\FilterFormat::getHtmlRestrictions() + function ($value, string $tag) use ($other) { + // If this HTML restrictions object contains a tag that the other did + // not contain at all: keep the DiffArray result. + if (!array_key_exists($tag, $other->elements)) { + return TRUE; + } + + // All subsequent checks can assume that $other contains an entry for + // this tag. + + // If this HTML restrictions object does not allow any attributes for + // this tag, then the other is at least equally restrictive: drop the + // DiffArray result. + if ($value === FALSE) { + return FALSE; + } + // If this HTML restrictions object allows any attributes for this + // tag, then the other is at most equally permissive: keep the + // DiffArray result. + if ($value === TRUE) { + return TRUE; + } + // Otherwise, this HTML restrictions object allows specific attributes + // only. DiffArray only knows to compare arrays. When the other object + // has a non-array value for this tag, interpret those values correctly. + assert(is_array($value)); + // The other object is more restrictive regarding allowed attributes + // for this tag: keep the DiffArray result. + if ($other->elements[$tag] === FALSE) { + return TRUE; + } + // The other object is more permissive regarding allowed attributes + // for this tag: drop the DiffArray result. + if ($other->elements[$tag] === TRUE) { + return FALSE; + } + // Both objects have lists of allowed attributes: keep the DiffArray + // result. + // @see ::validateAllowedRestrictionsPhase3() + assert(is_array($other->elements[$tag])); + return TRUE; + }, + ARRAY_FILTER_USE_BOTH + ); + + return new self($diff_elements); + } + + /** + * Computes intersection of two HTML restrictions, with wildcard support. + * + * @param \Drupal\ckeditor5\HTMLRestrictions $other + * The HTML restrictions to compare to. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * Returns a new HTML restrictions value object with all the elements that + * are also allowed in $other. + */ + public function intersect(HTMLRestrictions $other): HTMLRestrictions { + return self::applyOperation($this, $other, 'doIntersect'); + } + + /** + * Computes intersection of two HTML restrictions, without wildcard support. + * + * @param \Drupal\ckeditor5\HTMLRestrictions $other + * The HTML restrictions to compare to. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * Returns a new HTML restrictions value object with all the elements that + * are also allowed in $other. + */ + public function doIntersect(HTMLRestrictions $other): HTMLRestrictions { + $intersection_based_on_tags = array_intersect_key($this->elements, $other->elements); + $intersection = []; + // Additional filtering is necessary beyond the array_intersect_key that + // computed $intersection_based_on_tags because tag configuration can have + // boolean values that have different logic than array values. + foreach (array_keys($intersection_based_on_tags) as $tag) { + // If either does not allow attributes, neither does the intersection. + if ($this->elements[$tag] === FALSE || $other->elements[$tag] === FALSE) { + $intersection[$tag] = FALSE; + continue; + } + // If both allow all attributes, so does the intersection. + if ($this->elements[$tag] === TRUE && $other->elements[$tag] === TRUE) { + $intersection[$tag] = TRUE; + continue; + } + // If the first allows all attributes, return the second. + if ($this->elements[$tag] === TRUE) { + $intersection[$tag] = $other->elements[$tag]; + continue; + } + // And vice versa. + if ($other->elements[$tag] === TRUE) { + $intersection[$tag] = $this->elements[$tag]; + continue; + } + // In all other cases, we need to return the most restrictive + // intersection of per-attribute restrictions. + // @see ::validateAllowedRestrictionsPhase3() + assert(is_array($this->elements[$tag])); + assert(is_array($other->elements[$tag])); + $intersection[$tag] = []; + $attributes_intersection = array_intersect_key($this->elements[$tag], $other->elements[$tag]); + foreach (array_keys($attributes_intersection) as $attr) { + // If both allow all attribute values, so does the intersection. + if ($this->elements[$tag][$attr] === TRUE && $other->elements[$tag][$attr] === TRUE) { + $intersection[$tag][$attr] = TRUE; + continue; + } + // If the first allows all attribute values, return the second. + if ($this->elements[$tag][$attr] === TRUE) { + $intersection[$tag][$attr] = $other->elements[$tag][$attr]; + continue; + } + // And vice versa. + if ($other->elements[$tag][$attr] === TRUE) { + $intersection[$tag][$attr] = $this->elements[$tag][$attr]; + 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]); + // It is not permitted to specify an empty attribute value + // restrictions array. + if (empty($intersection[$tag][$attr])) { + unset($intersection[$tag][$attr]); + } + } + + // HTML tags must not have an empty array of allowed attributes. + if ($intersection[$tag] === []) { + $intersection[$tag] = FALSE; + } + } + + return new self($intersection); + } + + /** + * Computes set union of two HTML restrictions, with wildcard support. + * + * @param \Drupal\ckeditor5\HTMLRestrictions $other + * The HTML restrictions to compare to. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * Returns a new HTML restrictions value object with all the elements that + * are either allowed in $this or in $other. + */ + public function merge(HTMLRestrictions $other): HTMLRestrictions { + $union = array_merge_recursive($this->elements, $other->elements); + // When recursively merging elements arrays, unkeyed boolean values can + // appear in attribute config arrays. This removes them. + foreach ($union as $tag => $tag_config) { + if (is_array($tag_config)) { + // If the HTML tag restrictions for both operands were both booleans, + // then the result of array_merge_recursive() is an array containing two + // booleans (because it is designed for arrays, not for also merging + // booleans) under the first two numeric keys: 0 and 1. This does not + // match the structure expected of HTML restrictions. Combine the two + // booleans. + if (array_key_exists(0, $tag_config) && array_key_exists(1, $tag_config) && is_bool($tag_config[0]) && is_bool($tag_config[1])) { + // Twice FALSE. + if ($tag_config === [FALSE, FALSE]) { + $union[$tag] = FALSE; + } + // Once or twice TRUE. + else { + $union[$tag] = TRUE; + } + continue; + } + + // If the HTML tag restrictions for only one of the two operands was a + // boolean, then the result of array_merge_recursive() is an array + // containing the complete contents of the non-boolean operand plus an + // additional key-value pair with the first numeric key: 0. + if (array_key_exists(0, $tag_config)) { + // If the boolean was FALSE (meaning: "no attributes allowed"), then + // the other operand's values should be used in an union: this yields + // the most permissive result. + if ($tag_config[0] === FALSE) { + unset($union[$tag][0]); + } + // If the boolean was TRUE (meaning: "all attributes allowed"), then + // the other operand's values should be ignored in an union: this + // yields the most permissive result. + elseif ($tag_config[0] === TRUE) { + $union[$tag] = TRUE; + } + continue; + } + + // 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) { + continue; + } + + if (array_key_exists(0, $html_tag_attribute_restrictions)) { + // 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 + // values. If there is a numeric key, then one of the two operands + // must allow all attribute values (the "TRUE" case). Otherwise, an + // array merge would have happened, and no numeric key would exist. + // Therefore, this is always once or twice TRUE. + // e.g.: and , or and + assert($html_tag_attribute_restrictions[0] === TRUE || $html_tag_attribute_restrictions[1] === TRUE); + $union[$tag][$html_tag_attribute_name] = TRUE; + } + else { + // Finally, when both operands list the same allowed attribute + // values, then the result provided by array_merge_recursive() for + // those allowed attribute values is an array containing two times + // `TRUE` (because it is designed for arrays, not for also merging + // booleans) under the first two numeric keys: 0 and 1. + // e.g.: merged with . + foreach ($html_tag_attribute_restrictions as $allowed_attribute_value => $merged_result) { + if ($merged_result === [0 => TRUE, 1 => TRUE]) { + $union[$tag][$html_tag_attribute_name][$allowed_attribute_value] = TRUE; + } + } + } + } + } + } + return new self($union); + } + + /** + * Applies an operation (difference/intersection/union) with wildcard support. + * + * @param \Drupal\ckeditor5\HTMLRestrictions $a + * The first operand. + * @param \Drupal\ckeditor5\HTMLRestrictions $b + * The second operand. + * @param string $operation_method_name + * The name of the private method on this class to use as the operation. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * The result of the operation. + */ + private static function applyOperation(HTMLRestrictions $a, HTMLRestrictions $b, string $operation_method_name): HTMLRestrictions { + // 1. Operation applied to wildcard tags that exist in both operands. + // For example: <$block id> in both operands. + $a_wildcard = self::getWildcardSubset($a); + $b_wildcard = self::getWildcardSubset($b); + $wildcard_op_result = $a_wildcard->$operation_method_name($b_wildcard); + + // Early return if both operands contain only wildcard tags. + if (count($a_wildcard->elements) === count($a->elements) && count($b_wildcard->elements) === count($b->elements)) { + return $wildcard_op_result; + } + + // 2. Operation applied with wildcard tags resolved into concrete tags. + // For example:

in the first operand and + // <$block class="text-align-center"> in the second operand. + $a_concrete = self::resolveWildcards($a); + $b_concrete = self::resolveWildcards($b); + $concrete_op_result = $a_concrete->$operation_method_name($b_concrete); + + // Using the PHP array union operator is safe because the two operation + // result arrays ensure there is no overlap between the array keys. + // @codingStandardsIgnoreStart + assert(Inspector::assertAll(function ($t) { return self::isWildcardTag($t); }, array_keys($wildcard_op_result->elements))); + assert(Inspector::assertAll(function ($t) { return !self::isWildcardTag($t); }, array_keys($concrete_op_result->elements))); + // @codingStandardsIgnoreEnd + + return new self($concrete_op_result->elements + $wildcard_op_result->elements); + } + + /** + * Gets the subset of allowed elements whose tags are wildcards. + * + * @param \Drupal\ckeditor5\HTMLRestrictions $r + * A set of HTML restrictions. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * The subset of the given set of HTML restrictions. + */ + private static function getWildcardSubset(HTMLRestrictions $r): HTMLRestrictions { + return new self(array_filter($r->elements, [__CLASS__, 'isWildcardTag'], ARRAY_FILTER_USE_KEY)); + } + + /** + * Checks whether given tag is a wildcard. + * + * @param string $tag_name + * A tag name. + * + * @return bool + * TRUE if it is a wildcard, otherwise FALSE. + */ + private static function isWildcardTag(string $tag_name): bool { + return substr($tag_name, 0, 1) === '$' && array_key_exists($tag_name, self::WILDCARD_ELEMENT_METHODS); + } + + /** + * Resolves the wildcard tags (this consumes the wildcard tags). + * + * @param \Drupal\ckeditor5\HTMLRestrictions $r + * A set of HTML restrictions. + * + * @return \Drupal\ckeditor5\HTMLRestrictions + * The concrete interpretation of the given set of HTML restrictions. All + * wildcard tag restrictions are resolved into restrictions on concrete + * elements, if concrete elements are allowed that correspond to the + * wildcard tags. + * + * @see ::getWildcardTags() + */ + private static function resolveWildcards(HTMLRestrictions $r): HTMLRestrictions { + // Start by resolving the wildcards in a naive, simple way: generate + // tags, attributes and attribute values they support. + $naively_resolved_wildcard_elements = []; + foreach ($r->elements as $tag_name => $tag_config) { + if (self::isWildcardTag($tag_name)) { + $wildcard_tags = self::getWildcardTags($tag_name); + // Do not resolve to all tags supported by the wildcard tag, but only + // those which are explicitly supported. Because wildcard tags only + // allow declaring support for additional attributes and attribute + // values on already supported tags. + foreach ($wildcard_tags as $wildcard_tag) { + if (isset($r->elements[$wildcard_tag])) { + $naively_resolved_wildcard_elements[$wildcard_tag] = $tag_config; + } + } + } + } + $naive_resolution = new self($naively_resolved_wildcard_elements); + + // Now merge the naive resolution's elements with the original elements, to + // let ::merge() pick the most permissive one. + // This is necessary because resolving wildcards may result in concrete tags + // becoming either more permissive: + // - if $r is `

<$block class="foo">` + // - then $naive will be `

` + // - merging them yields `

<$block class="foo">` + // - diffing the wildcard subsets yields just `

` + // Or it could result in concrete tags being unaffected by the resolved + // wildcards: + // - if $r is `

<$block class="foo">` + // - then $naive will be `

` + // - merging them yields `

<$block class="foo">` again + // - diffing the wildcard subsets yields just `

` + return $r->merge($naive_resolution)->doDiff(self::getWildcardSubset($r)); + } + + /** + * Gets allowed elements. + * + * @param bool $resolve_wildcards + * (optional) Whether to resolve wildcards. Defaults to TRUE. When set to + * FALSE, the raw allowed elements will be returned (with no processing + * applied hence no resolved wildcards). + * + * @return array + * + * @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions() + */ + public function getAllowedElements(bool $resolve_wildcards = TRUE): array { + if ($resolve_wildcards) { + return self::resolveWildcards($this)->elements; + } + + return $this->elements; + } + + /** + * Transforms into the CKEditor 5 package metadata "elements" representation. + * + * @return string[] + * A list of strings, with each string expressing an allowed element, + * structured in the way expected by the CKEditor 5 package metadata. + * + * @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/package-metadata.html + */ + public function toCKEditor5ElementsArray(): array { + $readable = []; + foreach ($this->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; + } + + /** + * Transforms into the Drupal HTML filter's "allowed_html" representation. + * + * @return string + * A string representing the list of allowed elements, structured in the + * manner expected by the "Limit allowed HTML tags and correct faulty HTML" + * filter plugin. + * + * @see \Drupal\filter\Plugin\Filter\FilterHtml + */ + public function toFilterHtmlAllowedTagsString(): string { + return implode(' ', $this->toCKEditor5ElementsArray()); + } + + /** + * Transforms into the CKEditor 5 GHS configuration representation. + * + * @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 function toGeneralHtmlSupportConfig(): array { + $allowed = []; + foreach ($this->elements as $tag => $attributes) { + $to_allow = ['name' => $tag]; + assert($attributes === FALSE || is_array($attributes)); + if (is_array($attributes)) { + foreach ($attributes as $name => $value) { + // Convert the `'hreflang' => ['en' => TRUE, 'fr' => TRUE]` structure + // that this class expects to the `['en', 'fr']` structure that the + // GHS functionality in CKEditor 5 expects. + if (is_array($value)) { + $value = array_keys($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 string[] + * An array of block-level element tags. + */ + private static function getBlockElementList(): array { + return array_filter(array_keys(Elements::$html5), function (string $element): bool { + return Elements::isA($element, Elements::BLOCK_TAG); + }); + } + + /** + * Computes 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 string[] + * An array of HTML tags. + */ + private static function getWildcardTags(string $wildcard): array { + $wildcard_element_method = self::WILDCARD_ELEMENT_METHODS[$wildcard]; + return call_user_func([self::class, $wildcard_element_method]); + } + +} diff --git a/core/modules/ckeditor5/src/HTMLRestrictionsUtilities.php b/core/modules/ckeditor5/src/HTMLRestrictionsUtilities.php deleted file mode 100644 index 2e683cde4c..0000000000 --- a/core/modules/ckeditor5/src/HTMLRestrictionsUtilities.php +++ /dev/null @@ -1,284 +0,0 @@ - '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]); - } - -} diff --git a/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/SourceEditing.php b/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/SourceEditing.php index 11717006e9..49fe161fe8 100644 --- a/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/SourceEditing.php +++ b/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/SourceEditing.php @@ -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(), ], ]; } diff --git a/core/modules/ckeditor5/src/Plugin/CKEditor5PluginDefinition.php b/core/modules/ckeditor5/src/Plugin/CKEditor5PluginDefinition.php index 7be7aa398f..871e3bef74 100644 --- a/core/modules/ckeditor5/src/Plugin/CKEditor5PluginDefinition.php +++ b/core/modules/ckeditor5/src/Plugin/CKEditor5PluginDefinition.php @@ -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: "".', $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)); } } diff --git a/core/modules/ckeditor5/src/Plugin/CKEditor5PluginManager.php b/core/modules/ckeditor5/src/Plugin/CKEditor5PluginManager.php index 8a7053f33e..05290ebd6f 100644 --- a/core/modules/ckeditor5/src/Plugin/CKEditor5PluginManager.php +++ b/core/modules/ckeditor5/src/Plugin/CKEditor5PluginManager.php @@ -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(); } /** diff --git a/core/modules/ckeditor5/src/Plugin/CKEditor5PluginManagerInterface.php b/core/modules/ckeditor5/src/Plugin/CKEditor5PluginManagerInterface.php index 800afb2169..adf8967f91 100644 --- a/core/modules/ckeditor5/src/Plugin/CKEditor5PluginManagerInterface.php +++ b/core/modules/ckeditor5/src/Plugin/CKEditor5PluginManagerInterface.php @@ -104,9 +104,6 @@ public function getCKEditor5PluginConfig(EditorInterface $editor): array; * An array of plugin IDs. * @param \Drupal\editor\EditorInterface $editor * A configured text editor object. - * @param bool $retain_wildcard - * If TRUE, the returned array will include config for wildcard elements - * such as `<$block>`. * * @return array * A nested array with a structure as described in @@ -117,6 +114,6 @@ public function getCKEditor5PluginConfig(EditorInterface $editor): array; * * @see \Drupal\filter\Plugin\FilterInterface::getHTMLRestrictions() */ - public function getProvidedElements(array $plugin_ids = [], EditorInterface $editor = NULL, bool $retain_wildcard = FALSE): array; + public function getProvidedElements(array $plugin_ids = [], EditorInterface $editor = NULL): array; } diff --git a/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php b/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php index ed6cc6512e..849f5f7b15 100644 --- a/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php +++ b/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php @@ -4,7 +4,7 @@ namespace Drupal\ckeditor5\Plugin\Editor; -use Drupal\ckeditor5\HTMLRestrictionsUtilities; +use Drupal\ckeditor5\HTMLRestrictions; use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Heading; use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition; use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface; @@ -682,15 +682,14 @@ protected function getEventualEditorWithPrimedFilterFormat(SubformStateInterface if ($pair->getFilterFormat()->filters('filter_html')->status) { // Compute elements provided by the current CKEditor 5 settings. - $elements = $this->ckeditor5PluginManager->getProvidedElements(array_keys($enabled_plugins), $pair); + $restrictions = new HTMLRestrictions($this->ckeditor5PluginManager->getProvidedElements(array_keys($enabled_plugins), $pair)); // Compute eventual filter_html setting. Eventual as in: this is the list // of eventually allowed HTML tags. // @see \Drupal\filter\FilterFormatFormBase::submitForm() // @see ckeditor5_form_filter_format_form_alter() - $allowed_html = implode(' ', HTMLRestrictionsUtilities::toReadableElements($elements)); $filter_html_config = $pair->getFilterFormat()->filters('filter_html')->getConfiguration(); - $filter_html_config['settings']['allowed_html'] = $allowed_html; + $filter_html_config['settings']['allowed_html'] = $restrictions->toFilterHtmlAllowedTagsString(); $pair->getFilterFormat()->setFilterConfig('filter_html', $filter_html_config); } diff --git a/core/modules/ckeditor5/src/Plugin/Validation/Constraint/FundamentalCompatibilityConstraintValidator.php b/core/modules/ckeditor5/src/Plugin/Validation/Constraint/FundamentalCompatibilityConstraintValidator.php index 0c35b73879..073fd50e64 100644 --- a/core/modules/ckeditor5/src/Plugin/Validation/Constraint/FundamentalCompatibilityConstraintValidator.php +++ b/core/modules/ckeditor5/src/Plugin/Validation/Constraint/FundamentalCompatibilityConstraintValidator.php @@ -4,7 +4,7 @@ namespace Drupal\ckeditor5\Plugin\Validation\Constraint; -use Drupal\ckeditor5\HTMLRestrictionsUtilities; +use Drupal\ckeditor5\HTMLRestrictions; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\editor\EditorInterface; use Drupal\filter\FilterFormatInterface; @@ -101,10 +101,11 @@ private function checkNoMarkupFilters(FilterFormatInterface $text_format, Fundam * The constraint to validate. */ private function checkHtmlRestrictionsAreCompatible(FilterFormatInterface $text_format, FundamentalCompatibilityConstraint $constraint): void { - $minimum_tags = array_keys($this->pluginManager->getProvidedElements(self::FUNDAMENTAL_CKEDITOR5_PLUGINS)); + $fundamental = new HTMLRestrictions($this->pluginManager->getProvidedElements(self::FUNDAMENTAL_CKEDITOR5_PLUGINS)); + // @todo Remove in favor of HTMLRestrictions::diff() in https://www.drupal.org/project/drupal/issues/3231334 $html_restrictions = $text_format->getHtmlRestrictions(); - + $minimum_tags = array_keys($fundamental->getAllowedElements()); $forbidden_minimum_tags = isset($html_restrictions['forbidden_tags']) ? array_diff($minimum_tags, $html_restrictions['forbidden_tags']) : []; @@ -116,11 +117,12 @@ private function checkHtmlRestrictionsAreCompatible(FilterFormatInterface $text_ ->addViolation(); } - $not_allowed_minimum_tags = isset($html_restrictions['allowed']) - ? array_diff($minimum_tags, array_keys($html_restrictions['allowed'])) - : []; - if (!empty($not_allowed_minimum_tags)) { - $offending_filter = static::findHtmlRestrictorFilterNotAllowingTags($text_format, $minimum_tags); + // @todo Remove early return in https://www.drupal.org/project/drupal/issues/3231334 + if (!isset($html_restrictions['allowed'])) { + return; + } + if (!$fundamental->diff(HTMLRestrictions::fromTextFormat($text_format))->isEmpty()) { + $offending_filter = static::findHtmlRestrictorFilterNotAllowingTags($text_format, $fundamental); $this->context->buildViolation($constraint->nonAllowedElementsMessage) ->setParameter('%filter_label', (string) $offending_filter->getLabel()) ->setParameter('%filter_plugin_id', $offending_filter->getPluginId()) @@ -146,34 +148,23 @@ private function checkHtmlRestrictionsMatch(EditorInterface $text_editor, Fundam $provided = $this->pluginManager->getProvidedElements($enabled_plugins, $text_editor); foreach ($html_restrictor_filters as $filter_plugin_id => $filter) { - $restrictions = $filter->getHTMLRestrictions(); - if (!isset($restrictions['allowed'])) { - // @todo Handle HTML restrictor filters that only set forbidden_tags - // https://www.drupal.org/project/ckeditor5/issues/3231336. - continue; - } - - $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['*']); - } - - $diff_allowed = HTMLRestrictionsUtilities::diffAllowedElements($allowed, $provided); - $diff_elements = HTMLRestrictionsUtilities::diffAllowedElements($provided, $allowed); + $allowed = HTMLRestrictions::fromFilterPluginInstance($filter); + $provided = new HTMLRestrictions($provided); + $diff_allowed = $allowed->diff($provided); + $diff_elements = $provided->diff($allowed); - if (!empty($diff_allowed)) { + if (!$diff_allowed->isEmpty()) { $this->context->buildViolation($constraint->notSupportedElementsMessage) - ->setParameter('@list', implode(' ', HTMLRestrictionsUtilities::toReadableElements($provided))) - ->setParameter('@diff', implode(' ', HTMLRestrictionsUtilities::toReadableElements($diff_allowed))) + ->setParameter('@list', $provided->toFilterHtmlAllowedTagsString()) + ->setParameter('@diff', $diff_allowed->toFilterHtmlAllowedTagsString()) ->atPath("filters.$filter_plugin_id") ->addViolation(); } - elseif (!empty($diff_elements)) { + + if (!$diff_elements->isEmpty()) { $this->context->buildViolation($constraint->missingElementsMessage) - ->setParameter('@list', implode(' ', HTMLRestrictionsUtilities::toReadableElements($provided))) - ->setParameter('@diff', implode(' ', HTMLRestrictionsUtilities::toReadableElements($diff_elements))) + ->setParameter('@list', $provided->toFilterHtmlAllowedTagsString()) + ->setParameter('@diff', $diff_elements->toFilterHtmlAllowedTagsString()) ->atPath("filters.$filter_plugin_id") ->addViolation(); } @@ -255,15 +246,15 @@ function (FilterInterface $filter) { * * @param \Drupal\filter\FilterFormatInterface $text_format * A text format whose filters to check for compatibility. - * @param string[] $required_tags - * A list of HTML tags that are required. + * @param \Drupal\ckeditor5\HTMLRestrictions $required + * A set of HTML restrictions, listing required HTML tags. * * @return \Drupal\filter\Plugin\FilterInterface * The filter plugin instance not allowing the required tags. * * @throws \InvalidArgumentException */ - private static function findHtmlRestrictorFilterNotAllowingTags(FilterFormatInterface $text_format, array $required_tags): FilterInterface { + private static function findHtmlRestrictorFilterNotAllowingTags(FilterFormatInterface $text_format, HTMLRestrictions $required): FilterInterface { // Get HTML restrictor filters that actually restrict HTML. $filters = static::getFiltersInFormatOfType( $text_format, @@ -274,9 +265,8 @@ function (FilterInterface $filter) { ); foreach ($filters as $filter) { - $restrictions = $filter->getHTMLRestrictions(); - - if (isset($restrictions['allowed']) && !empty(array_diff($required_tags, array_keys($restrictions['allowed'])))) { + // Return any filter not allowing >=1 of the required tags. + if (!$required->diff(HTMLRestrictions::fromFilterPluginInstance($filter))->isEmpty()) { return $filter; } } diff --git a/core/modules/ckeditor5/src/Plugin/Validation/Constraint/SourceEditingRedundantTagsConstraintValidator.php b/core/modules/ckeditor5/src/Plugin/Validation/Constraint/SourceEditingRedundantTagsConstraintValidator.php index 9f3e5bb4e1..a07fad7d02 100644 --- a/core/modules/ckeditor5/src/Plugin/Validation/Constraint/SourceEditingRedundantTagsConstraintValidator.php +++ b/core/modules/ckeditor5/src/Plugin/Validation/Constraint/SourceEditingRedundantTagsConstraintValidator.php @@ -4,7 +4,7 @@ namespace Drupal\ckeditor5\Plugin\Validation\Constraint; -use Drupal\ckeditor5\HTMLRestrictionsUtilities; +use Drupal\ckeditor5\HTMLRestrictions; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -40,24 +40,36 @@ public function validate($value, Constraint $constraint) { unset($enabled_plugins['ckeditor5_sourceEditing']); // An array of tags enabled by every plugin other than Source Editing. - $enabled_plugin_tags = $this->pluginManager->getProvidedElements(array_keys($enabled_plugins)); - $disabled_plugin_tags = $this->pluginManager->getProvidedElements(array_keys($disabled_plugins)); + $enabled_plugin_tags = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($enabled_plugins))); + $disabled_plugin_tags = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($disabled_plugins))); - // An array of just the tags enabled by Source Editing. - $source_enabled_tags = HTMLRestrictionsUtilities::allowedElementsStringToHtmlFilterArray($value); - $enabled_plugin_overlap = array_intersect_key($enabled_plugin_tags, $source_enabled_tags); - $disabled_plugin_overlap = array_intersect_key($disabled_plugin_tags, $source_enabled_tags); + // The single tag for which source editing is enabled, which we are checking + // now. + $source_enabled_tags = HTMLRestrictions::fromString($value); + // @todo Remove this early return in + // https://www.drupal.org/project/drupal/issues/2820364. It is only + // necessary because CKEditor5ElementConstraintValidator does not run + // before this, which means that this validator cannot assume it receives + // valid values. + if ($source_enabled_tags->isEmpty() || count($source_enabled_tags->getAllowedElements()) > 1) { + return; + } + // This validation constraint currently only validates tags, not attributes; + // so if all attributes are allowed (TRUE) or some attributes are allowed + // (an array), return early. Only proceed when no attributes are allowed + // (FALSE). + // @todo Support attributes and attribute values in + // https://www.drupal.org/project/drupal/issues/3260857 + $tags = array_keys($source_enabled_tags->getAllowedElements()); + if ($source_enabled_tags->getAllowedElements()[reset($tags)] !== FALSE) { + return; + } - foreach ([$enabled_plugin_overlap, $disabled_plugin_overlap] as &$overlap) { + $enabled_plugin_overlap = $enabled_plugin_tags->intersect($source_enabled_tags); + $disabled_plugin_overlap = $disabled_plugin_tags->intersect($source_enabled_tags); + foreach ([$enabled_plugin_overlap, $disabled_plugin_overlap] as $overlap) { $checking_enabled = $overlap === $enabled_plugin_overlap; - if (!empty($overlap)) { - foreach ($overlap as $overlapping_tag => $overlapping_config) { - if (is_array($source_enabled_tags[$overlapping_tag])) { - unset($overlap[$overlapping_tag]); - } - } - } - if (!empty($overlap)) { + if (!$overlap->isEmpty()) { $plugins_to_check_against = $checking_enabled ? $enabled_plugins : $disabled_plugins; $tags_plugin_report = $this->pluginsSupplyingTagsMessage($overlap, $plugins_to_check_against); $message = $checking_enabled ? $constraint->enabledPluginsMessage : $constraint->availablePluginsMessage; @@ -71,7 +83,7 @@ public function validate($value, Constraint $constraint) { /** * Creates a message listing plugins and the overlapping tags they provide. * - * @param array $tags + * @param \Drupal\ckeditor5\HTMLRestrictions $overlap * An array of overlapping tags. * @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[] $plugin_definitions * An array of plugin definitions where overlap was found. @@ -79,16 +91,14 @@ public function validate($value, Constraint $constraint) { * @return string * A list of plugins that provide the overlapping tags. */ - private function pluginsSupplyingTagsMessage(array $tags, array $plugin_definitions): string { + private function pluginsSupplyingTagsMessage(HTMLRestrictions $overlap, array $plugin_definitions): string { $message_array = []; $message_string = ''; foreach ($plugin_definitions as $definition) { if ($definition->hasElements()) { - $elements_array = HTMLRestrictionsUtilities::allowedElementsStringToHtmlFilterArray(implode('', $definition->getElements())); - foreach ($elements_array as $tag_name => $tag_config) { - if (isset($tags[$tag_name])) { - $message_array[(string) $definition->label()][] = "<$tag_name>"; - } + $plugin_capabilities = HTMLRestrictions::fromString(implode(' ', $definition->getElements())); + foreach ($plugin_capabilities->intersect($overlap)->toCKEditor5ElementsArray() as $element) { + $message_array[(string) $definition->label()][] = $element; } } } diff --git a/core/modules/ckeditor5/src/SmartDefaultSettings.php b/core/modules/ckeditor5/src/SmartDefaultSettings.php index 0fb8adabf1..59986631e0 100644 --- a/core/modules/ckeditor5/src/SmartDefaultSettings.php +++ b/core/modules/ckeditor5/src/SmartDefaultSettings.php @@ -139,13 +139,11 @@ public function computeSmartDefaultSettings(?EditorInterface $text_editor, Filte ['%enabling_message_content' => $enabling_message_content], ); } - unset($unsupported['*']); // Warn user about unsupported tags. if (!empty($unsupported)) { - $unsupported_string = implode(' ', HTMLRestrictionsUtilities::toReadableElements($unsupported)); - $this->addTagsToSourceEditing($editor, $unsupported_string); + $this->addTagsToSourceEditing($editor, $unsupported); $messages[] = $this->t("The following tags were permitted by this format's filter configuration, but no plugin was available that supports them. To ensure the tags remain supported by this text format, the following were added to the Source Editing plugin's Manually editable HTML tags: @unsupported_string.", [ - '@unsupported_string' => $unsupported_string, + '@unsupported_string' => $unsupported->toFilterHtmlAllowedTagsString(), ]); } } @@ -164,7 +162,7 @@ public function computeSmartDefaultSettings(?EditorInterface $text_editor, Filte if ($missing_attributes) { $this->addTagsToSourceEditing($editor, $missing_attributes); $messages[] = $this->t("This format's HTML filters includes plugins that support the following tags, but not some of their attributes. To ensure these attributes remain supported by this text format, the following were added to the Source Editing plugin's Manually editable HTML tags: @missing_attributes.", [ - '@missing_attributes' => $missing_attributes, + '@missing_attributes' => $missing_attributes->toFilterHtmlAllowedTagsString(), ]); } } @@ -179,15 +177,16 @@ public function computeSmartDefaultSettings(?EditorInterface $text_editor, Filte return [$editor, $messages]; } - private function addTagsToSourceEditing(EditorInterface $editor, string $tags): array { + private function addTagsToSourceEditing(EditorInterface $editor, HTMLRestrictions $tags): array { $messages = []; $settings = $editor->getSettings(); if (!isset($settings['toolbar']['items']) || !in_array('sourceEditing', $settings['toolbar']['items'])) { $messages[] = $this->t('The Source Editing plugin was enabled to support tags and/or attributes that are not explicitly supported by any available CKEditor 5 plugins.'); $settings['toolbar']['items'][] = 'sourceEditing'; } - $source_editing_allowed_tags = $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] ?? []; - $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = array_merge($source_editing_allowed_tags, HTMLRestrictionsUtilities::allowedElementsStringToPluginElementsArray($tags)); + $allowed_tags_array = $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] ?? []; + $allowed_tags_string = implode(' ', $allowed_tags_array); + $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = HTMLRestrictions::fromString($allowed_tags_string)->merge($tags)->toCKEditor5ElementsArray(); $editor->setSettings($settings); return $messages; } @@ -338,7 +337,7 @@ protected function getEnabledCkeditor4Plugins(EditorInterface $editor): array { * NULL when nothing happened, otherwise an array with two values: * 1. a description (for use in a message) of which CKEditor 5 plugins were * enabled to match the HTML tags allowed by the text format. - * 2. the unsupported tags + * 2. the unsupported elements, in an HTMLRestrictions value object */ private function addToolbarItemsToMatchHtmlTagsInFormat(FilterFormatInterface $format, EditorInterface $editor): ?array { $html_restrictions_needed_elements = $format->getHtmlRestrictions(); @@ -396,13 +395,14 @@ private function addToolbarItemsToMatchHtmlTagsInFormat(FilterFormatInterface $f } } + unset($unsupported['*']); if (!empty($enabling_message_content)) { $editor->setSettings($editor_settings_to_update); $enabling_message_content = substr($enabling_message_content, 0, -1); - return [$enabling_message_content, $unsupported]; + return [$enabling_message_content, new HTMLRestrictions($unsupported)]; } else { - return [NULL, $unsupported]; + return [NULL, new HTMLRestrictions($unsupported)]; } } @@ -418,7 +418,7 @@ private function addToolbarItemsToMatchHtmlTagsInFormat(FilterFormatInterface $f * NULL when nothing happened, otherwise an array with two values: * 1. a description (for use in a message) of which CKEditor 5 plugins were * enabled to match the HTML attributes allowed by the text format. - * 2. the unsupported attributes + * 2. the unsupported elements, in an HTMLRestrictions value object */ private function addToolbarItemsToMatchHtmlAttributesInFormat(FilterFormatInterface $format, EditorInterface $editor): ?array { $html_restrictions_needed_elements = $format->getHtmlRestrictions(); @@ -428,116 +428,43 @@ private function addToolbarItemsToMatchHtmlAttributesInFormat(FilterFormatInterf $enabled_plugins = array_keys($this->pluginManager->getEnabledDefinitions($editor)); $provided_elements = $this->pluginManager->getProvidedElements($enabled_plugins); - $missing = HTMLRestrictionsUtilities::diffAllowedElements($editor->getFilterFormat()->getHtmlRestrictions()['allowed'], $provided_elements); - $supported_tags_with_unsupported_attributes = array_intersect_key($missing, $provided_elements); + $provided = new HTMLRestrictions($provided_elements); + $missing = HTMLRestrictions::fromTextFormat($format)->diff($provided); + $supported_tags_with_unsupported_attributes = array_intersect_key($missing->getAllowedElements(), $provided_elements); $supported_tags_with_unsupported_attributes = array_filter($supported_tags_with_unsupported_attributes, function ($tag_config) { return is_array($tag_config); }); + $still_needed = new HTMLRestrictions($supported_tags_with_unsupported_attributes); - if (!empty($supported_tags_with_unsupported_attributes)) { - // This will be populated with plugins that aren't currently enabled, but - // provide element configuration that include attributes. I.e. they are - // the only plugins that can potentially address unsupported attributes - // in supported tags. - $disabled_plugins_with_attribute_config = []; + if (!$still_needed->isEmpty()) { $all_plugins_definitions = $this->pluginManager->getDefinitions(); foreach ($all_plugins_definitions as $plugin_id => $definition) { // Only proceed if the plugin has configured elements and the plugin // does not have conditions. In the future we could add support for // automatically enabling filters, but for now we assume that the filter // configuration cannot be modified. - if (!in_array($plugin_id, $enabled_plugins) && !$definition->hasConditions()) { - $plugins_provided_elements = $this->pluginManager->getProvidedElements([$plugin_id], NULL, TRUE); - if (!empty($plugins_provided_elements)) { - // Filter elements that do not have attribute configuration. - $elements_with_attribute_config = array_filter($plugins_provided_elements, function ($elements) { - return $elements !== FALSE; - }); - if (!empty($elements_with_attribute_config)) { - foreach ($elements_with_attribute_config as $tag_name => $attribute_config) { - // If the 'tag' is a wildcard, add the attribute config to - // all qualifying tags. - if (substr($tag_name, 0, 1) === '$') { - // An array of all the tags that match the wildcard value. - $wildcard_tags = HTMLRestrictionsUtilities::getWildcardTags($tag_name); - - // Matching wildcard tags that are also tags that have - // attribute config that is not yet supported. - $wildcard_tags_in_config_missing_attributes = array_intersect_key(array_flip($wildcard_tags), $supported_tags_with_unsupported_attributes); - foreach (array_keys($wildcard_tags_in_config_missing_attributes) as $wildcard_provided_tag) { - $elements_with_attribute_config[$wildcard_provided_tag] = $attribute_config; - } - - // Remove the wildcard 'tag', as the tags it represents are - // now accounted for. - unset($elements_with_attribute_config[$tag_name]); - } - } - $disabled_plugins_with_attribute_config[$plugin_id] = $elements_with_attribute_config; - } - } - } - } - - // This will contain plugins to be enabled if they provide support for the - // not-yet-supported attributes. - $plugins_to_enable_to_support_attribute_config = []; - foreach ($supported_tags_with_unsupported_attributes as $tag_name => $attributes_config) { - foreach ($attributes_config as $attribute_name => $attribute_config) { - // This means the existing config must allow all values of the - // attribute. - if ($attribute_config === TRUE) { - // See if there is a disabled plugin that will provide full use of - // the attribute for a given tag. - foreach ($disabled_plugins_with_attribute_config as $disabled_plugin_id => $disabled_plugin_elements_config) { - if (isset($disabled_plugin_elements_config[$tag_name][$attribute_name]) && $disabled_plugin_elements_config[$tag_name][$attribute_name] === TRUE) { - // Add this to the 'plugins to enable' array. Setting this value - // to TRUE instead of an array indicates to the message system - // that the attribute is allowed for the tag with any value. - $plugins_to_enable_to_support_attribute_config[$disabled_plugin_id][$attribute_name][$tag_name] = TRUE; - - // This attribute can be removed from the list of unsupported - // attributes for the tag. - unset($supported_tags_with_unsupported_attributes[$tag_name][$attribute_name]); - } - } - } - else { - // This condition is reached if the existing configuration has the - // attribute value restricted to specific values. - // @todo currently, this will enable plugins that allow ALL values - // for an attribute. This means the attribute+value is now allowed - // but additional attribute values are permitted as well. This may - // need to be more selective - // https://www.drupal.org/project/ckeditor5/issues/3231328. - foreach ($attribute_config as $allowed_attribute_value => $noop) { - foreach ($disabled_plugins_with_attribute_config as $disabled_plugin_id => $disabled_plugin_config) { - if (isset($disabled_plugin_config[$tag_name][$attribute_name])) { - if ($disabled_plugin_config[$tag_name][$attribute_name] === TRUE) { - unset($supported_tags_with_unsupported_attributes[$tag_name][$attribute_name]); - $plugins_to_enable_to_support_attribute_config[$disabled_plugin_id][$attribute_name][$tag_name] = TRUE; - } - elseif (is_array($disabled_plugin_config[$tag_name][$attribute_name])) { - foreach ($disabled_plugin_config[$tag_name][$attribute_name] as $disabled_plugin_attribute_name => $disabled_plugin_allowed_value) { - if ($disabled_plugin_attribute_name === $allowed_attribute_value) { - unset($supported_tags_with_unsupported_attributes[$tag_name][$attribute_name][$allowed_attribute_value]); - if (empty($supported_tags_with_unsupported_attributes[$tag_name][$attribute_name])) { - unset($supported_tags_with_unsupported_attributes[$tag_name][$attribute_name]); - } - $plugins_to_enable_to_support_attribute_config[$disabled_plugin_id][$attribute_name][$tag_name][] = $allowed_attribute_value; - } - } - } - } + if (!in_array($plugin_id, $enabled_plugins, TRUE) && !$definition->hasConditions() && $definition->hasElements()) { + $plugin_support = HTMLRestrictions::fromString(implode(' ', $definition->getElements())); + // Do not inspect just $plugin_support, but the union of that with the + // already supported elements: wildcard restrictions will only resolve + // if the concrete tags they support are also present. + $potential_future = $provided->merge($plugin_support); + // This is the heart of the operation: intersect the potential future + // with what we need to achieve, then subtract what is already + // supported. This yields the net new elements. + $net_new = $potential_future->intersect($still_needed)->diff($provided); + if (!$net_new->isEmpty()) { + foreach ($net_new->getAllowedElements() as $tag_name => $attributes_config) { + foreach ($attributes_config as $attribute_name => $attribute_config) { + $plugins_to_enable_to_support_attribute_config[$plugin_id][$attribute_name][$tag_name] = $attribute_config; } } + // Fewer attributes are still needed. + $still_needed = $still_needed->diff($net_new); } } } - $supported_tags_with_unsupported_attributes = array_filter($supported_tags_with_unsupported_attributes); - $missing_attributes = implode(' ', HTMLRestrictionsUtilities::toReadableElements($supported_tags_with_unsupported_attributes)); - // If additional plugins need to be enable to support attribute config, // loop through the list to enable the plugins and build a UI message that // will convey this plugin-enabling to the user. @@ -555,7 +482,7 @@ private function addToolbarItemsToMatchHtmlAttributesInFormat(FilterFormatInterf $enabled_for_attributes_message_content .= " for tag: <$tag_name> to support: $attribute_name"; if (is_array($attribute_value_config)) { $enabled_for_attributes_message_content .= " with value(s): "; - foreach ($attribute_value_config as $allowed_value) { + foreach (array_keys($attribute_value_config) as $allowed_value) { $enabled_for_attributes_message_content .= " $allowed_value,"; } $enabled_for_attributes_message_content = substr($enabled_for_attributes_message_content, 0, -1) . '), '; @@ -568,14 +495,14 @@ private function addToolbarItemsToMatchHtmlAttributesInFormat(FilterFormatInterf // Some plugins enabled, maybe some missing attributes. return [ substr($enabled_for_attributes_message_content, 0, -2), - $missing_attributes, + $still_needed, ]; } else { // No plugins enabled, maybe some missing attributes. return [ NULL, - $missing_attributes, + $still_needed, ]; } } diff --git a/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php b/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php index d638a4c3ac..9c3b0d45df 100644 --- a/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php +++ b/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php @@ -3,7 +3,7 @@ namespace Drupal\Tests\ckeditor5\Kernel; use Composer\Autoload\ClassLoader; -use Drupal\ckeditor5\HTMLRestrictionsUtilities; +use Drupal\ckeditor5\HTMLRestrictions; use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Heading; use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; use Drupal\Core\DependencyInjection\ContainerBuilder; @@ -1020,7 +1020,6 @@ public function testEnabledPlugins() { * in the filter_html "Allowed tags" field. * * @covers \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getProvidedElements - * @covers \Drupal\ckeditor5\HTMLRestrictionsUtilities::toReadableElements * @dataProvider providerTestProvidedElements */ public function testProvidedElements(array $plugins, array $text_editor_settings, array $expected_elements, string $expected_readable_string) { @@ -1040,8 +1039,7 @@ public function testProvidedElements(array $plugins, array $text_editor_settings $provided_elements = $this->manager->getProvidedElements($plugins, $text_editor); $this->assertSame($expected_elements, $provided_elements); - $readable_string = implode(' ', HTMLRestrictionsUtilities::toReadableElements($provided_elements)); - $this->assertSame($expected_readable_string, $readable_string); + $this->assertSame($expected_readable_string, (new HTMLRestrictions($provided_elements))->toFilterHtmlAllowedTagsString()); } /** diff --git a/core/modules/ckeditor5/tests/src/Kernel/SmartDefaultSettingsTest.php b/core/modules/ckeditor5/tests/src/Kernel/SmartDefaultSettingsTest.php index abe7693f98..a2ff35e0ef 100644 --- a/core/modules/ckeditor5/tests/src/Kernel/SmartDefaultSettingsTest.php +++ b/core/modules/ckeditor5/tests/src/Kernel/SmartDefaultSettingsTest.php @@ -4,7 +4,7 @@ namespace Drupal\Tests\ckeditor5\Kernel; -use Drupal\ckeditor5\HTMLRestrictionsUtilities; +use Drupal\ckeditor5\HTMLRestrictions; use Drupal\Component\Utility\NestedArray; use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; @@ -306,21 +306,20 @@ public function test(string $format_id, array $filters_to_drop, array $expected_ // If the text format has HTML restrictions, ensure that a strict superset // is allowed after switching to CKEditor 5. $html_restrictions = $text_format->getHtmlRestrictions(); - $allowed_tags = $html_restrictions['allowed'] ?? []; - if ($allowed_tags) { - unset($allowed_tags['*']); + if (is_array($html_restrictions) && array_key_exists('allowed', $html_restrictions)) { + $allowed_tags = HTMLRestrictions::fromTextFormat($text_format); $enabled_plugins = array_keys($this->manager->getEnabledDefinitions($updated_text_editor)); - $updated_allowed_tags = $this->manager->getProvidedElements($enabled_plugins, $updated_text_editor); - $unsupported_tags_attributes = HTMLRestrictionsUtilities::diffAllowedElements($allowed_tags, $updated_allowed_tags); - $superset_tags_attributes = HTMLRestrictionsUtilities::diffAllowedElements($updated_allowed_tags, $allowed_tags); - $this->assertSame($expected_superset, implode(' ', HTMLRestrictionsUtilities::toReadableElements($superset_tags_attributes))); - $this->assertEmpty($unsupported_tags_attributes, "The following tags/attributes are not allowed in the updated text format:" . print_r($unsupported_tags_attributes, TRUE)); + $updated_allowed_tags = new HTMLRestrictions($this->manager->getProvidedElements($enabled_plugins, $updated_text_editor)); + $unsupported_tags_attributes = $allowed_tags->diff($updated_allowed_tags); + $superset_tags_attributes = $updated_allowed_tags->diff($allowed_tags); + $this->assertSame($expected_superset, $superset_tags_attributes->toFilterHtmlAllowedTagsString()); + $this->assertTrue($unsupported_tags_attributes->isEmpty(), "The following tags/attributes are not allowed in the updated text format:" . $unsupported_tags_attributes->toFilterHtmlAllowedTagsString()); // Update the text format like ckeditor5_form_filter_format_form_alter() // would. $updated_text_format = clone $text_format; $filter_html_config = $text_format->filters('filter_html')->getConfiguration(); - $filter_html_config['settings']['allowed_html'] = implode(' ', HTMLRestrictionsUtilities::toReadableElements($updated_allowed_tags)); + $filter_html_config['settings']['allowed_html'] = $updated_allowed_tags->toFilterHtmlAllowedTagsString(); $updated_text_format->setFilterConfig('filter_html', $filter_html_config); } else { @@ -499,11 +498,8 @@ public function provider() { 'toolbar' => [ 'items' => array_merge( $basic_html_test_case['expected_ckeditor5_settings']['toolbar']['items'], - // @todo Improve in https://www.drupal.org/project/drupal/issues/3259593 [ 'alignment', - 'alignment:center', - 'alignment:justify', ] ), ], @@ -536,7 +532,7 @@ public function provider() { 'expected_messages' => array_merge($basic_html_test_case['expected_messages'], [ - 'The following plugins were enabled to support specific attributes that are allowed by this text format: Alignment ( for tag: <p> to support: class with value(s): text-align-center, text-align-justify), Align center ( for tag: <p> to support: class with value(s): text-align-center), Justify ( for tag: <p> to support: class with value(s): text-align-justify).', + 'The following plugins were enabled to support specific attributes that are allowed by this text format: Alignment ( for tag: <p> to support: class with value(s): text-align-center, text-align-justify).', 'This format\'s HTML filters includes plugins that support the following tags, but not some of their attributes. To ensure these attributes remain supported by this text format, the following were added to the Source Editing plugin\'s Manually editable HTML tags: <a hreflang> <blockquote cite> <ul type> <ol start type> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>.', ]), ]; diff --git a/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php b/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php index 2cb9442e70..48093d6d77 100644 --- a/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php +++ b/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php @@ -712,6 +712,8 @@ public function providerPair(): array { 'roy', '<#donk>', 'cruft', + '', + ' ', ], ], ], @@ -724,6 +726,8 @@ public function providerPair(): array { 'settings.plugins.ckeditor5_sourceEditing.allowed_tags.2' => 'The following tag is not valid HTML: roy.', 'settings.plugins.ckeditor5_sourceEditing.allowed_tags.3' => 'The following tag is not valid HTML: <#donk>.', 'settings.plugins.ckeditor5_sourceEditing.allowed_tags.4' => 'The following tag is not valid HTML: <junior>cruft.', + 'settings.plugins.ckeditor5_sourceEditing.allowed_tags.5' => 'The following tag is not valid HTML: .', + 'settings.plugins.ckeditor5_sourceEditing.allowed_tags.6' => 'The following tag is not valid HTML: .', ], ]; diff --git a/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php b/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php new file mode 100644 index 0000000000..4a87794202 --- /dev/null +++ b/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php @@ -0,0 +1,895 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expected_exception_message); + } + new HTMLRestrictions($elements); + } + + public function providerConstruct(): \Generator { + // Fundamental structure. + yield 'INVALID: list instead of key-value pairs' => [ + ['', ''], + 'An array of key-value pairs must be provided, with HTML tag names as keys.', + ]; + + // Invalid HTML tag names. + yield 'INVALID: key-value pairs now, but invalid keys due to angular brackets' => [ + ['' => '', ' ' => ''], + '"" is not a HTML tag name, it is an actual HTML tag. Omit the angular brackets.', + ]; + yield 'INVALID: no more angular brackets, but still leading or trailing whitespace' => [ + ['foo' => '', 'bar ' => ''], + 'The "bar " HTML tag contains trailing or leading whitespace.', + ]; + yield 'INVALID: invalid character range' => [ + ['🦙' => ''], + '"🦙" is not a valid HTML tag name.', + ]; + yield 'INVALID: invalid custom element name' => [ + ['foo-bar' => '', '1-foo-bar' => ''], + '"1-foo-bar" is not a valid HTML tag name.', + ]; + yield 'INVALID: unknown wildcard element name' => [ + ['$foo' => TRUE], + '"$foo" is not a valid HTML tag name.', + ]; + + // Invalid HTML tag attribute name restrictions. + yield 'INVALID: keys valid, but not yet the values' => [ + ['foo' => '', 'bar' => ''], + 'The value for the "foo" HTML tag is neither a boolean nor an array of attribute restrictions.', + ]; + yield 'INVALID: keys valid, values can be arrays … but not empty arrays' => [ + ['foo' => [], 'bar' => []], + 'The value for the "foo" HTML tag is an empty array. This is not permitted, specify FALSE instead to indicate no attributes are allowed. Otherwise, list allowed attributes.', + ]; + yield 'INVALID: keys valid, values invalid attribute restrictions' => [ + ['foo' => ['baz'], 'bar' => [' qux']], + 'The "foo" HTML tag has attribute restrictions, but it is not an array of key-value pairs, with HTML tag attribute names as keys.', + ]; + yield 'INVALID: keys valid, values invalid attribute restrictions due to invalid attribute name' => [ + ['foo' => ['baz' => ''], 'bar' => [' qux' => '']], + 'The "bar" HTML tag has an attribute restriction " qux" which contains whitespace. Omit the whitespace.', + ]; + + // Invalid HTML tag attribute value restrictions. + yield 'INVALID: keys valid, values invalid attribute restrictions due to empty strings' => [ + ['foo' => ['baz' => ''], 'bar' => ['qux' => '']], + 'The "foo" HTML tag has an attribute restriction "baz" which is neither TRUE nor an array of attribute value restrictions.', + ]; + yield 'INVALID: keys valid, values invalid attribute restrictions due to an empty array of allowed attribute values' => [ + ['foo' => ['baz' => TRUE], 'bar' => ['qux' => []]], + 'The "bar" HTML tag has an attribute restriction "qux" which is set to the empty array. This is not permitted, specify either TRUE to allow all attribute values, or list the attribute value restrictions.', + ]; + yield 'INVALID: keys valid, values invalid attribute restrictions due to a list of allowed attribute values' => [ + ['foo' => ['baz' => TRUE], 'bar' => ['qux' => ['a', 'b']]], + 'The "bar" HTML tag has attribute restriction "qux", but it is not an array of key-value pairs, with HTML tag attribute values as keys and TRUE as values.', + ]; + + // Valid values. + yield 'VALID: keys valid, boolean attribute restriction values: also valid' => [ + ['foo' => TRUE, 'bar' => FALSE], + NULL, + ]; + yield 'VALID: keys valid, array attribute restriction values: also valid' => [ + ['foo' => ['baz' => TRUE], 'bar' => ['qux' => ['a' => TRUE, 'b' => TRUE]]], + NULL, + ]; + } + + /** + * @covers ::isEmpty() + * @covers ::getAllowedElements() + * @dataProvider providerCounting + */ + public function testCounting(array $elements, bool $expected_is_empty, int $expected_concrete_only_count, int $expected_concrete_plus_wildcard_count): void { + $r = new HTMLRestrictions($elements); + $this->assertSame($expected_is_empty, $r->isEmpty()); + $this->assertCount($expected_concrete_only_count, $r->getAllowedElements()); + $this->assertCount($expected_concrete_only_count, $r->getAllowedElements(TRUE)); + $this->assertCount($expected_concrete_plus_wildcard_count, $r->getAllowedElements(FALSE)); + } + + public function providerCounting(): \Generator { + yield 'empty' => [ + [], + TRUE, + 0, + 0, + ]; + + yield 'one' => [ + ['a' => TRUE], + FALSE, + 1, + 1, + ]; + + yield 'two' => [ + ['a' => TRUE, 'b' => FALSE], + FALSE, + 2, + 2, + ]; + + yield 'two of which one is a wildcard' => [ + ['a' => TRUE, '$block' => FALSE], + FALSE, + 1, + 2, + ]; + } + + /** + * @covers ::fromString() + * @covers ::fromTextFormat() + * @covers ::fromFilterPluginInstance() + * @dataProvider providerConvenienceConstructors + */ + public function testConvenienceConstructors($input, array $expected, ?array $expected_raw = NULL): void { + $expected_raw = $expected_raw ?? $expected; + + // ::fromString() + $this->assertSame($expected, HTMLRestrictions::fromString($input)->getAllowedElements()); + $this->assertSame($expected_raw, HTMLRestrictions::fromString($input)->getAllowedElements(FALSE)); + + // ::fromTextFormat() + $text_format = $this->prophesize(FilterFormatInterface::class); + $text_format->getHTMLRestrictions()->willReturn([ + 'allowed' => $expected_raw, + ]); + $this->assertSame($expected, HTMLRestrictions::fromTextFormat($text_format->reveal())->getAllowedElements()); + $this->assertSame($expected_raw, HTMLRestrictions::fromTextFormat($text_format->reveal())->getAllowedElements(FALSE)); + + // ::fromFilterPluginInstance() + $filter_plugin_instance = $this->prophesize(FilterInterface::class); + $filter_plugin_instance->getHTMLRestrictions()->willReturn([ + 'allowed' => $expected_raw + [ + // @see \Drupal\filter\Plugin\Filter\FilterHtml::getHTMLRestrictions() + '*' => [ + 'style' => FALSE, + 'on*' => FALSE, + 'lang' => TRUE, + 'dir' => ['ltr' => TRUE, 'rtl' => TRUE], + ], + ], + ]); + $this->assertSame($expected, HTMLRestrictions::fromFilterPluginInstance($filter_plugin_instance->reveal())->getAllowedElements()); + $this->assertSame($expected_raw, HTMLRestrictions::fromFilterPluginInstance($filter_plugin_instance->reveal())->getAllowedElements(FALSE)); + } + + public function providerConvenienceConstructors(): \Generator { + // All empty cases. + yield 'empty string' => [ + '', + [], + ]; + yield 'empty array' => [ + implode(' ', []), + [], + ]; + yield 'whitespace string' => [ + ' ', + [], + ]; + + // Some nonsense cases. + yield 'nonsense string' => [ + 'Hello there, this looks nothing like a HTML restriction.', + [], + ]; + yield 'nonsense array #1' => [ + implode(' ', ['foo', 'bar']), + [], + ]; + yield 'nonsense array #2' => [ + implode(' ', ['foo' => TRUE, 'bar' => FALSE]), + [], + ]; + + // Single tag cases. + yield 'tag without attributes' => [ + '', + ['a' => FALSE], + ]; + yield 'tag with wildcard attribute' => [ + '', + ['a' => TRUE], + ]; + yield 'tag with single attribute allowing any value' => [ + '', + ['a' => ['target' => TRUE]], + ]; + yield 'tag with single attribute allowing single specific value' => [ + '', + ['a' => ['target' => ['_blank' => TRUE]]], + ]; + yield 'tag with single attribute allowing multiple specific values' => [ + '', + ['a' => ['target' => ['_self' => TRUE, '_blank' => TRUE]]], + ]; + yield 'tag with single attribute allowing multiple specific values (reverse order)' => [ + '', + ['a' => ['target' => ['_blank' => TRUE, '_self' => TRUE]]], + ]; + yield 'tag with two attributes' => [ + '', + ['a' => ['target' => TRUE, 'class' => TRUE]], + ]; + yield 'tag with two attributes, one with a partial wildcard' => [ + '', + ['a' => ['target' => TRUE, 'class' => TRUE]], + ]; + + // Multiple tag cases. + yield 'two tags' => [ + '

', + ['a' => FALSE, 'p' => FALSE], + ]; + yield 'two tags (reverse order)' => [ + '

', + ['a' => FALSE, 'p' => FALSE], + ]; + + // Wildcard tag. + yield '$block' => [ + '<$block class="text-align-left text-align-center text-align-right text-align-justify">', + [], + [ + '$block' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + ], + ]; + yield '$block + one concrete tag to resolve into' => [ + '

<$block class="text-align-left text-align-center text-align-right text-align-justify">', + [ + 'p' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + ], + [ + 'p' => FALSE, + '$block' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + ], + ]; + yield '$block + two concrete tag to resolve into' => [ + '

<$block class="text-align-left text-align-center text-align-right text-align-justify">

', + [ + 'p' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + 'blockquote' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + ], + [ + 'p' => FALSE, + 'blockquote' => FALSE, + '$block' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + ], + ]; + yield '$block + one concrete tag to resolve into that already allows a subset of attributes: concrete less permissive than wildcard' => [ + '

<$block class="text-align-left text-align-center text-align-right text-align-justify">', + [ + 'p' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + ], + [ + 'p' => [ + 'class' => [ + 'text-align-left' => TRUE, + ], + ], + '$block' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + ], + ]; + yield '$block + one concrete tag to resolve into that already allows all attribute values: concrete more permissive than wildcard' => [ + '

<$block class="text-align-left text-align-center text-align-right text-align-justify">', + [ + 'p' => [ + 'class' => TRUE, + ], + ], + [ + 'p' => [ + 'class' => TRUE, + ], + '$block' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + ], + ]; + yield '$block + one concrete tag to resolve into that already allows all attributes: concrete more permissive than wildcard' => [ + '

<$block class="text-align-left text-align-center text-align-right text-align-justify">', + [ + 'p' => TRUE, + ], + [ + 'p' => TRUE, + '$block' => [ + 'class' => [ + 'text-align-left' => TRUE, + 'text-align-center' => TRUE, + 'text-align-right' => TRUE, + 'text-align-justify' => TRUE, + ], + ], + ], + ]; + + // @todo Test `data-*` attribute: https://www.drupal.org/project/drupal/issues/3260853 + } + + /** + * @covers ::toCKEditor5ElementsArray() + * @covers ::toFilterHtmlAllowedTagsString() + * @covers ::toGeneralHtmlSupportConfig() + * @dataProvider providerRepresentations + */ + public function testRepresentations(HTMLRestrictions $restrictions, array $expected_elements_array, string $expected_allowed_html_string, array $expected_ghs_config): void { + $this->assertSame($expected_elements_array, $restrictions->toCKEditor5ElementsArray()); + $this->assertSame($expected_allowed_html_string, $restrictions->toFilterHtmlAllowedTagsString()); + $this->assertSame($expected_ghs_config, $restrictions->toGeneralHtmlSupportConfig()); + } + + public function providerRepresentations(): \Generator { + yield 'empty set' => [ + HTMLRestrictions::emptySet(), + [], + '', + [], + ]; + + yield 'only tags' => [ + new HTMLRestrictions(['a' => FALSE, 'p' => FALSE, 'br' => FALSE]), + ['', '

', '
'], + '


', + [ + ['name' => 'a'], + ['name' => 'p'], + ['name' => 'br'], + ], + ]; + + yield 'single tag with multiple attributes allowing all values' => [ + new HTMLRestrictions(['script' => ['src' => TRUE, 'defer' => TRUE]]), + ['