Commit f7e9db42 authored by Lauri Timmanee's avatar Lauri Timmanee Committed by Ben Mullins
Browse files

Issue #3260853 by Wim Leers, bnjmnm: [GHS] Partial wildcard attributes (<foo...

Issue #3260853 by Wim Leers, bnjmnm:  [GHS] Partial wildcard attributes (<foo data-*>, <foo *-bar-*>, <foo *-bar>) and attribute values (<h2 id="jump-*">) not yet supported

(cherry picked from commit 359a11a7)
parent d7aa8c47
Loading
Loading
Loading
Loading
+159 −8
Original line number Diff line number Diff line
@@ -164,6 +164,9 @@ private static function validateAllowedRestrictionsPhase3(array $elements): void
        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));
        }
        if ($html_tag_attribute_name === '*') {
          throw new \InvalidArgumentException(sprintf('The "%s" HTML tag has an attribute restriction "*". This implies all attributes are allowed. Remove the attribute restriction instead, or use a prefix (`*-foo`), infix (`*-foo-*`) or suffix (`foo-*`) wildcard restriction instead.', $html_tag_name));
        }
      }
    }
  }
@@ -413,6 +416,40 @@ function ($value, string $tag) use ($other) {
      ARRAY_FILTER_USE_BOTH
    );

    // Special case: wildcard attributes, and the ability to define restrictions
    // for all concrete attributes matching them using:
    // - prefix wildcard, f.e. `data-*`, to match `data-foo`, `data-bar`, etc.
    // - infix wildcard, f.e. `*-entity-*`
    // - suffix wildcard, f.e. `foo-*`
    foreach ($diff_elements as $tag => $tag_config) {
      // If there are no per-attribute restrictions for this tag in either
      // operand, then no wildcard attribute postprocessing is needed.
      if (!(isset($other->elements[$tag]) && is_array($other->elements[$tag]))) {
        continue;
      }
      $wildcard_attributes = array_filter(array_keys($other->elements[$tag]), [__CLASS__, 'isWildcardAttributeName']);
      foreach ($wildcard_attributes as $wildcard_attribute_name) {
        $regex = self::getRegExForWildCardAttributeName($wildcard_attribute_name);
        foreach ($tag_config as $html_tag_attribute_name => $html_tag_attribute_restrictions) {
          // If a wildcard attribute name (f.e. `data-*`) is allowed in $other
          // with the same attribute value restrictions (e.g. TRUE to allow all
          // attribute values or an array of specific allowed attribute values),
          // then all concrete matches (f.e. `data-foo`, `data-bar`, etc.) are
          // allowed and should be explicitly omitted from the difference.
          if ($html_tag_attribute_restrictions === $other->elements[$tag][$wildcard_attribute_name] && preg_match($regex, $html_tag_attribute_name) === 1) {
            unset($tag_config[$html_tag_attribute_name]);
          }
        }

        if ($tag_config !== []) {
          $diff_elements[$tag] = $tag_config;
        }
        else {
          unset($diff_elements[$tag]);
        }
      }
    }

    return new self($diff_elements);
  }

@@ -506,6 +543,52 @@ public function doIntersect(HTMLRestrictions $other): HTMLRestrictions {
      }
    }

    // Special case: wildcard attributes, and the ability to define restrictions
    // for all concrete attributes matching them using:
    // - prefix wildcard, f.e. `data-*`, to match `data-foo`, `data-bar`, etc.
    // - infix wildcard, f.e. `*-entity-*`
    // - suffix wildcard, f.e. `foo-*`
    foreach ($intersection as $tag => $tag_config) {
      // If there are no per-attribute restrictions for this tag in either
      // operand, then no wildcard attribute postprocessing is needed.
      if (!(is_array($this->elements[$tag]) && is_array($other->elements[$tag]))) {
        continue;
      }
      $other_wildcard_attributes = array_filter(array_keys($other->elements[$tag]), [__CLASS__, 'isWildcardAttributeName']);
      $this_wildcard_attributes = array_filter(array_keys($this->elements[$tag]), [__CLASS__, 'isWildcardAttributeName']);

      // If the same wildcard attribute restrictions are present in both or
      // neither, no adjustment necessary: the intersection is already correct.
      $in_both = array_intersect($other_wildcard_attributes, $this_wildcard_attributes);
      $other_wildcard_attributes = array_diff($other_wildcard_attributes, $in_both);
      $this_wildcard_attributes = array_diff($this_wildcard_attributes, $in_both);
      $wildcard_attributes_to_analyze = array_merge($other_wildcard_attributes, $this_wildcard_attributes);
      if (empty($wildcard_attributes_to_analyze)) {
        continue;
      }

      // Otherwise, the wildcard attribute name (f.e. `data-*`) is allowed in
      // one of the two with the same attribute value restrictions (e.g. TRUE to
      // allow all attribute values, or an array of specific allowed attribute
      // values), and the intersection must contain the most restrictive
      // configuration.
      foreach ($wildcard_attributes_to_analyze as $wildcard_attribute_name) {
        $other_has_wildcard = isset($other->elements[$tag][$wildcard_attribute_name]);
        $wildcard_operand = $other_has_wildcard ? $other : $this;
        $concrete_operand = $other_has_wildcard ? $this : $other;
        $concrete_tag_config = $concrete_operand->elements[$tag];
        $wildcard_attribute_restriction = $wildcard_operand->elements[$tag][$wildcard_attribute_name];
        $regex = self::getRegExForWildCardAttributeName($wildcard_attribute_name);
        foreach ($concrete_tag_config as $html_tag_attribute_name => $html_tag_attribute_restrictions) {
          if ($html_tag_attribute_restrictions === $wildcard_attribute_restriction && preg_match($regex, $html_tag_attribute_name) === 1) {
            $tag_config = $tag_config === FALSE ? [] : $tag_config;
            $tag_config[$html_tag_attribute_name] = $html_tag_attribute_restrictions;
          }
        }
        $intersection[$tag] = $tag_config;
      }
    }

    return new self($intersection);
  }

@@ -598,6 +681,39 @@ public function merge(HTMLRestrictions $other): HTMLRestrictions {
        }
      }
    }

    // Special case: wildcard attributes, and the ability to define restrictions
    // for all concrete attributes matching them using:
    // - prefix wildcard, f.e. `data-*`, to match `data-foo`, `data-bar`, etc.
    // - infix wildcard, f.e. `*-entity-*`
    // - suffix wildcard, f.e. `foo-*`
    foreach ($union as $tag => $tag_config) {
      // If there are no per-attribute restrictions for this tag, then no
      // wildcard attribute postprocessing is needed.
      if (!is_array($tag_config)) {
        continue;
      }
      $wildcard_attributes = array_filter(array_keys($tag_config), [__CLASS__, 'isWildcardAttributeName']);
      foreach ($wildcard_attributes as $wildcard_attribute_name) {
        $regex = self::getRegExForWildCardAttributeName($wildcard_attribute_name);
        foreach ($tag_config as $html_tag_attribute_name => $html_tag_attribute_restrictions) {
          // The wildcard attribute restriction itself must be kept.
          if ($html_tag_attribute_name === $wildcard_attribute_name) {
            continue;
          }
          // If a concrete attribute restriction (f.e. `data-foo`, `data-bar`,
          // etc.) exists whose attribute value restrictions are the same as the
          // wildcard attribute value restrictions (f.e. `data-*`), we must
          // explicitly drop the concrete attribute restriction in favor of the
          // wildcard one.
          if ($html_tag_attribute_restrictions === $tag_config[$wildcard_attribute_name] && preg_match($regex, $html_tag_attribute_name) === 1) {
            unset($tag_config[$html_tag_attribute_name]);
          }
        }
        $union[$tag] = $tag_config;
      }
    }

    return new self($union);
  }

@@ -643,6 +759,35 @@ private static function applyOperation(HTMLRestrictions $a, HTMLRestrictions $b,
    return new self($concrete_op_result->elements + $wildcard_op_result->elements);
  }

  /**
   * Checks whether the given attribute name contains a wildcard, e.g. `data-*`.
   *
   * @param string $attribute_name
   *   The attribute name to check.
   *
   * @return bool
   *   Whether the given attribute name contains a wildcard.
   */
  private static function isWildcardAttributeName(string $attribute_name): bool {
    // @see ::validateAllowedRestrictionsPhase3()
    assert($attribute_name !== '*');
    return strpos($attribute_name, '*') !== FALSE;
  }

  /**
   * Computes a regular expression for matching a wildcard attribute name.
   *
   * @param string $wildcard_attribute_name
   *   The wildcard attribute name for which to compute a regular expression.
   *
   * @return string
   *   The computed regular expression.
   */
  private static function getRegExForWildCardAttributeName(string $wildcard_attribute_name): string {
    assert(self::isWildcardAttributeName($wildcard_attribute_name));
    return '/^' . str_replace('*', '.*', $wildcard_attribute_name) . '$/';
  }

  /**
   * Gets the subset of allowed elements whose tags are wildcards.
   *
@@ -793,6 +938,7 @@ public function toFilterHtmlAllowedTagsString(): string {
   *   CKEditor 5 htmlSupport plugin constructor.
   *
   * @see https://ckeditor5.github.io/docs/nightly/ckeditor5/latest/features/general-html-support.html#configuration
   * @see https://ckeditor5.github.io/docs/nightly/ckeditor5/latest/api/module_engine_view_matcher-MatcherPattern.html
   */
  public function toGeneralHtmlSupportConfig(): array {
    $allowed = [];
@@ -813,20 +959,25 @@ public function toGeneralHtmlSupportConfig(): array {
            continue;
          }
          assert($value === TRUE || Inspector::assertAllStrings($value));
          if ($name === 'class') {
            $to_allow['classes'] = $value;
            continue;
          }
          // If a single attribute value is allowed, it must be TRUE (see the
          // assertion above). Otherwise, it must be an array of strings (see
          // the assertion above), which lists all allowed attribute values. To
          // be able to configure GHS to a range of values, we need to use a
          // regular expression.
          // @todo Expand to support partial wildcards in
          //   https://www.drupal.org/project/drupal/issues/3260853.
          $to_allow['attributes'][$name] = is_array($value)
            ? ['regexp' => ['pattern' => '/^(' . implode('|', $value) . ')$/']]
          $allowed_attribute_value = is_array($value)
            ? ['regexp' => ['pattern' => '/^(' . implode('|', str_replace('*', '.*', $value)) . ')$/']]
            : $value;
          if ($name === 'class') {
            $to_allow['classes'] = $allowed_attribute_value;
            continue;
          }
          // Most attribute restrictions specify a concrete attribute name. When
          // the attribute name contains a partial wildcard, more complex syntax
          // is needed.
          $to_allow['attributes'][] = [
            'key' => strpos($name, '*') === FALSE ? $name : ['regexp' => ['pattern' => self::getRegExForWildCardAttributeName($name)]],
            'value' => $allowed_attribute_value,
          ];
        }
      }
      $allowed[] = $to_allow;
+37 −0
Original line number Diff line number Diff line
@@ -9,6 +9,8 @@
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolation;

// cspell:ignore gramma

/**
 * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing
 * @group ckeditor5
@@ -197,6 +199,41 @@ public function providerAllowingExtraAttributes(): array {
        '<a class>',
      ],

      // Edge case: wildcard attribute names:
      // - prefix, f.e. `data-*`
      // - infix, f.e. `*gramma*`
      // - suffix, f.e. `*-grammar`
      '<a data-*>' => [
        '<p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p>',
        '<a data-*>',
      ],
      '<a *gramma*>' => [
        '<p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p>',
        '<a *gramma*>',
      ],
      '<a *-grammar>' => [
        '<p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p>',
        '<a *-grammar>',
      ],

      // Edge case: concrete attribute with wildcard class value.
      '<a class="use-*">' => [
        '<p>The <a href="https://example.com/pirate">pirate</a> is <a class="use-ajax" href="https://example.com/irate">irate</a>.</p>',
        '<a class="use-*">',
      ],

      // Edge case: concrete attribute with wildcard attribute value.
      '<a data-grammar="sub*">' => [
        '<p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate">irate</a>.</p>',
        '<a data-grammar="sub*">',
      ],

      // Edge case: `data-*` with wildcard attribute value.
      '<a data-*="sub*">' => [
        '<p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate">irate</a>.</p>',
        '<a data-*="sub*">',
      ],

      // Edge case: `style`.
      // @todo https://www.drupal.org/project/drupal/issues/3260857
    ];
+34 −0
Original line number Diff line number Diff line
@@ -133,6 +133,18 @@ protected function setUp(): void {
    $basic_html_editor_with_media_embed->setSettings($settings);
    $basic_html_editor_with_media_embed->save();

    $new_value = str_replace('<img src alt height width data-entity-type data-entity-uuid data-align data-caption>', '<img src alt height width data-*>', $current_value);
    $basic_html_format_with_any_data_attr = $basic_html_format;
    $basic_html_format_with_any_data_attr['name'] .= ' (with any data-* attribute on images)';
    $basic_html_format_with_any_data_attr['format'] = 'basic_html_with_any_data_attr';
    NestedArray::setValue($basic_html_format_with_any_data_attr, $allowed_html_parents, $new_value);
    FilterFormat::create($basic_html_format_with_any_data_attr)->save();
    Editor::create(
      ['format' => 'basic_html_with_any_data_attr']
      +
      Yaml::parseFile('core/profiles/standard/config/install/editor.editor.basic_html.yml')
    )->save();

    $filter_plugin_manager = $this->container->get('plugin.manager.filter');
    FilterFormat::create([
      'format' => 'filter_only__filter_html',
@@ -561,6 +573,28 @@ public function provider() {
      ]),
    ];

    yield "basic_html_with_any_data_attr can be switched to CKEditor 5 without problems (3 upgrade messages)" => [
      'format_id' => 'basic_html_with_any_data_attr',
      'filters_to_drop' => $basic_html_test_case['filters_to_drop'],
      'expected_ckeditor5_settings' => [
        'toolbar' => $basic_html_test_case['expected_ckeditor5_settings']['toolbar'],
        'plugins' => [
          'ckeditor5_sourceEditing' => [
            'allowed_tags' => array_merge(
              $basic_html_test_case['expected_ckeditor5_settings']['plugins']['ckeditor5_sourceEditing']['allowed_tags'],
              ['<img data-*>'],
            ),
          ],
        ] + $basic_html_test_case['expected_ckeditor5_settings']['plugins'],
      ],
      'expected_superset' => $basic_html_test_case['expected_superset'],
      'expected_fundamental_compatibility_violations' => $basic_html_test_case['expected_fundamental_compatibility_violations'],
      'expected_messages' => array_merge($basic_html_test_case['expected_messages'],
        [
          '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 <em>Manually editable HTML tags</em>: &lt;a hreflang&gt; &lt;blockquote cite&gt; &lt;ul type&gt; &lt;ol start type&gt; &lt;h2 id&gt; &lt;h3 id&gt; &lt;h4 id&gt; &lt;h5 id&gt; &lt;h6 id&gt; &lt;img data-*&gt;.',
        ]),
    ];

    yield "restricted_html can be switched to CKEditor 5 after dropping the two markup-creating filters (3 upgrade messages)" => [
      'format_id' => 'restricted_html',
      'filters_to_drop' => [
+199 −18
Original line number Diff line number Diff line
@@ -73,6 +73,10 @@ public function providerConstruct(): \Generator {
      ['foo' => ['baz' => ''], 'bar' => [' qux' => '']],
      'The "bar" HTML tag has an attribute restriction " qux" which contains whitespace. Omit the whitespace.',
    ];
    yield 'INVALID: keys valid, values invalid attribute restrictions due to broad wildcard instead of prefix/infix/suffix wildcard attribute name' => [
      ['foo' => ['*' => TRUE]],
      'The "foo" HTML tag has an attribute restriction "*". This implies all attributes are allowed. Remove the attribute restriction instead, or use a prefix (`*-foo`), infix (`*-foo-*`) or suffix (`foo-*`) wildcard restriction instead.',
    ];

    // Invalid HTML tag attribute value restrictions.
    yield 'INVALID: keys valid, values invalid attribute restrictions due to empty strings' => [
@@ -238,10 +242,6 @@ public function providerConvenienceConstructors(): \Generator {
      '<a target class>',
      ['a' => ['target' => TRUE, 'class' => TRUE]],
    ];
    yield 'tag with two attributes, one with a partial wildcard' => [
      '<a target class>',
      ['a' => ['target' => TRUE, 'class' => TRUE]],
    ];

    // Multiple tag cases.
    yield 'two tags' => [
@@ -253,7 +253,7 @@ public function providerConvenienceConstructors(): \Generator {
      ['a' => FALSE, 'p' => FALSE],
    ];

    // Wildcard tag.
    // Wildcard tag, attribute and attribute value.
    yield '$block' => [
      '<$block class="text-align-left text-align-center text-align-right text-align-justify">',
      [],
@@ -391,8 +391,22 @@ public function providerConvenienceConstructors(): \Generator {
        ],
      ],
    ];

    // @todo Test `data-*` attribute: https://www.drupal.org/project/drupal/issues/3260853
    yield '<drupal-media data-*>' => [
      '<drupal-media data-*>',
      ['drupal-media' => ['data-*' => TRUE]],
    ];
    yield '<drupal-media foo-*-bar>' => [
      '<drupal-media foo-*-bar>',
      ['drupal-media' => ['foo-*-bar' => TRUE]],
    ];
    yield '<drupal-media *-foo>' => [
      '<drupal-media *-foo>',
      ['drupal-media' => ['*-foo' => TRUE]],
    ];
    yield '<h2 id="jump-*">' => [
      '<h2 id="jump-*">',
      ['h2' => ['id' => ['jump-*' => TRUE]]],
    ];
  }

  /**
@@ -434,8 +448,8 @@ public function providerRepresentations(): \Generator {
        [
          'name' => 'script',
          'attributes' => [
            'src' => TRUE,
            'defer' => TRUE,
            ['key' => 'src', 'value' => TRUE],
            ['key' => 'defer', 'value' => TRUE],
          ],
        ],
      ],
@@ -449,26 +463,140 @@ public function providerRepresentations(): \Generator {
        [
          'name' => 'a',
          'attributes' => [
            'href' => TRUE,
            'hreflang' => [
            ['key' => 'href', 'value' => TRUE],
            [
              'key' => 'hreflang',
              'value' => [
                'regexp' => [
                  'pattern' => '/^(en|fr)$/',
                ],
              ],
            ],
          ],
        ],
        [
          'name' => 'p',
          'attributes' => [
            'data-*' => TRUE,
            [
              'key' => [
                'regexp' => [
                  'pattern' => '/^data-.*$/',
                ],
              ],
              'value' => TRUE,
            ],
          ],
          'classes' => [
            'block',
            'regexp' => [
              'pattern' => '/^(block)$/',
            ],
          ],
        ],
        ['name' => 'br'],
      ],
    ];

    // Wildcard tag, attribute and attribute value.
    yield '$block' => [
      new HTMLRestrictions(['$block' => ['data-*' => TRUE]]),
      ['<$block data-*>'],
      '<$block data-*>',
      [
        [
          'name' => '$block',
          'attributes' => [
            [
              'key' => [
                'regexp' => [
                  'pattern' => '/^data-.*$/',
                ],
              ],
              'value' => TRUE,
            ],
          ],
        ],
      ],
    ];
    yield '<drupal-media data-*>' => [
      new HTMLRestrictions(['drupal-media' => ['data-*' => TRUE]]),
      ['<drupal-media data-*>'],
      '<drupal-media data-*>',
      [
        [
          'name' => 'drupal-media',
          'attributes' => [
            [
              'key' => [
                'regexp' => [
                  'pattern' => '/^data-.*$/',
                ],
              ],
              'value' => TRUE,
            ],
          ],
        ],
      ],
    ];
    yield '<drupal-media foo-*-bar>' => [
      new HTMLRestrictions(['drupal-media' => ['foo-*-bar' => TRUE]]),
      ['<drupal-media foo-*-bar>'],
      '<drupal-media foo-*-bar>',
      [
        [
          'name' => 'drupal-media',
          'attributes' => [
            [
              'key' => [
                'regexp' => [
                  'pattern' => '/^foo-.*-bar$/',
                ],
              ],
              'value' => TRUE,
            ],
          ],
        ],
      ],
    ];
    yield '<drupal-media *-bar>' => [
      new HTMLRestrictions(['drupal-media' => ['*-bar' => TRUE]]),
      ['<drupal-media *-bar>'],
      '<drupal-media *-bar>',
      [
        [
          'name' => 'drupal-media',
          'attributes' => [
            [
              'key' => [
                'regexp' => [
                  'pattern' => '/^.*-bar$/',
                ],
              ],
              'value' => TRUE,
            ],
          ],
        ],
      ],
    ];
    yield '<h2 id="jump-*">' => [
      new HTMLRestrictions(['h2' => ['id' => ['jump-*' => TRUE]]]),
      ['<h2 id="jump-*">'],
      '<h2 id="jump-*">',
      [
        [
          'name' => 'h2',
          'attributes' => [
            [
              'key' => 'id',
              'value' => [
                'regexp' => [
                  'pattern' => '/^(jump-.*)$/',
                ],
              ],
            ],
          ],
        ],
      ],
    ];
  }

  /**
@@ -738,7 +866,7 @@ public function providerOperands(): \Generator {
      'union' => 'b',
    ];

    // Wildcard + matching tag cases.
    // Wildcard tag + matching tag cases.
    yield 'wildcard + matching tag: attribute intersection — without possible resolving' => [
      'a' => new HTMLRestrictions(['p' => ['class' => TRUE]]),
      'b' => new HTMLRestrictions(['$block' => ['class' => TRUE]]),
@@ -810,7 +938,7 @@ public function providerOperands(): \Generator {
      'union' => 'b',
    ];

    // Wildcard + non-matching cases.
    // Wildcard tag + non-matching tag cases.
    yield 'wildcard + non-matching tag: attribute diff — without possible resolving' => [
      'a' => new HTMLRestrictions(['span' => ['class' => TRUE]]),
      'b' => new HTMLRestrictions(['$block' => ['class' => TRUE]]),
@@ -868,7 +996,7 @@ public function providerOperands(): \Generator {
      'union' => new HTMLRestrictions(['span' => ['class' => ['vertical-align-top' => TRUE, 'vertical-align-bottom' => TRUE]], '$block' => ['class' => ['vertical-align-top' => TRUE]]]),
    ];

    // Wildcard + wildcard cases.
    // Wildcard tag + wildcard tag cases.
    yield 'wildcard + wildcard tag: attributes' => [
      'a' => new HTMLRestrictions(['$block' => ['class' => TRUE, 'foo' => TRUE]]),
      'b' => new HTMLRestrictions(['$block' => ['class' => TRUE]]),
@@ -897,6 +1025,59 @@ public function providerOperands(): \Generator {
      'intersection' => 'a',
      'union' => 'b',
    ];

    // Concrete attributes + wildcard attribute cases for all 3 possible
    // wildcard locations. Parametrized to prevent excessive repetition and
    // subtle differences.
    $wildcard_locations = [
      'prefix' => 'data-*',
      'infix' => '*-entity-*',
      'suffix' => '*-type',
    ];
    foreach ($wildcard_locations as $wildcard_location => $wildcard_attr_name) {
      yield "concrete attrs + wildcard $wildcard_location attr that covers a superset" => [
        'a' => new HTMLRestrictions(['img' => ['data-entity-bundle-type' => TRUE, 'data-entity-type' => TRUE]]),
        'b' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE]]),
        'diff' => HTMLRestrictions::emptySet(),
        'intersection' => 'a',
        'union' => 'b',
      ];
      yield "concrete attrs + wildcard $wildcard_location attr that covers a superset — vice versa" => [
        'a' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE]]),
        'b' => new HTMLRestrictions(['img' => ['data-entity-bundle-type' => TRUE, 'data-entity-type' => TRUE]]),
        'diff' => 'a',
        'intersection' => 'b',
        'union' => 'a',
      ];
      yield "concrete attrs + wildcard $wildcard_location attr that covers a subset" => [
        'a' => new HTMLRestrictions(['img' => ['data-entity-bundle-type' => TRUE, 'data-entity-type' => TRUE, 'class' => TRUE]]),
        'b' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE]]),
        'diff' => new HTMLRestrictions(['img' => ['class' => TRUE]]),
        'intersection' => new HTMLRestrictions(['img' => ['data-entity-bundle-type' => TRUE, 'data-entity-type' => TRUE]]),
        'union' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE, 'class' => TRUE]]),
      ];
      yield "concrete attrs + wildcard $wildcard_location attr that covers a subset — vice versa" => [
        'a' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE]]),
        'b' => new HTMLRestrictions(['img' => ['data-entity-bundle-type' => TRUE, 'data-entity-type' => TRUE, 'class' => TRUE]]),
        'diff' => 'a',
        'intersection' => new HTMLRestrictions(['img' => ['data-entity-bundle-type' => TRUE, 'data-entity-type' => TRUE]]),
        'union' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE, 'class' => TRUE]]),
      ];
      yield "wildcard $wildcard_location attr + wildcard $wildcard_location attr" => [
        'a' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE, 'class' => TRUE]]),
        'b' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE]]),
        'diff' => new HTMLRestrictions(['img' => ['class' => TRUE]]),
        'intersection' => 'b',
        'union' => 'a',
      ];
      yield "wildcard $wildcard_location attr + wildcard $wildcard_location attr — vice versa" => [
        'a' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE]]),
        'b' => new HTMLRestrictions(['img' => [$wildcard_attr_name => TRUE, 'class' => TRUE]]),
        'diff' => HTMLRestrictions::emptySet(),
        'intersection' => 'a',
        'union' => 'b',
      ];
    }
  }

}