ckeditor5-1.0.x-dev/src/Plugin/Validation/Constraint/FundamentalCompatibilityConstraintValidator.php
src/Plugin/Validation/Constraint/FundamentalCompatibilityConstraintValidator.php
<?php
declare(strict_types = 1);
namespace Drupal\ckeditor5\Plugin\Validation\Constraint;
use Drupal\Component\Utility\DiffArray;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\filter\FilterFormatInterface;
use Drupal\filter\Plugin\FilterInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validates fundamental compatibility of CKEditor 5 with the given text format.
*
* Fundamental requirements:
* 1. No TYPE_MARKUP_LANGUAGE filters allowed.
* 2. Fundamental CKEditor 5 plugins' HTML tags are allowed.
* 3. The HTML restrictions of all TYPE_HTML_RESTRICTOR filters allow the
* configured CKEditor 5 plugins to work.
*
* @see \Drupal\filter\Plugin\FilterInterface::TYPE_HTML_RESTRICTOR
*/
class FundamentalCompatibilityConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
use CKEditor5ConstraintValidatorTrait;
/**
* The fundamental CKEditor 5 plugins without which it cannot function.
*
* @var string[]
*/
const FUNDAMENTAL_CKEDITOR5_PLUGINS = [
'ckeditor5.essentials',
'ckeditor5.paragraph',
];
/**
* {@inheritdoc}
*/
public function validate($toolbar_item, Constraint $constraint) {
if (!$constraint instanceof FundamentalCompatibilityConstraint) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\FundamentalCompatibility');
}
// First: the two fundamental checks against the text format. If any of
// them adds a constraint violation, return early, because it is a
// fundamental compatibility problem.
$text_format = FilterFormat::create(['filters' => $this->context->getRoot()->get('filters')->toArray()]);
assert($text_format instanceof FilterFormatInterface);
$this->checkNoMarkupFilters($text_format, $constraint);
if ($this->context->getViolations()->count() > 0) {
return;
}
$this->checkHtmlRestrictionsAreCompatible($text_format, $constraint);
if ($this->context->getViolations()->count() > 0) {
return;
}
// Finally: ensure the CKEditor 5 configuration's ability to generate HTML
// markup precisely matches that of the text format.
$text_editor = Editor::create([
'editor' => 'ckeditor5',
'settings' => $this->context->getRoot()->get('settings')->toArray(),
'image_upload' => $this->context->getRoot()->get('image_upload')->toArray(),
// Specify `filterFormat` to ensure that the generated Editor config
// entity object already has the $filterFormat property set, to prevent
// calls to Editor::hasAssociatedFilterFormat() and
// Editor::getFilterFormat() from loading the FilterFormat from storage.
// As far as this validation constraint validator is concerned, the
// concrete FilterFormat entity ID does not matter, all that matters is
// its filter configuration. Those exist in $text_format.
'filterFormat' => $text_format,
]);
assert($text_editor instanceof EditorInterface);
$this->checkHtmlRestrictionsMatch($text_format, $text_editor, $constraint);
}
/**
* Checks no TYPE_MARKUP_LANGUAGE filters are present.
*
* @param \Drupal\filter\FilterFormatInterface $text_format
* The text format to validate.
* @param \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraint $constraint
* The constraint to validate.
*/
private function checkNoMarkupFilters(FilterFormatInterface $text_format, FundamentalCompatibilityConstraint $constraint) : void {
$markup_filters = static::getFiltersInFormatOfType(
$text_format,
FilterInterface::TYPE_MARKUP_LANGUAGE
);
if (!empty($markup_filters)) {
foreach ($markup_filters as $markup_filter) {
$this->context->buildViolation($constraint->noMarkupFiltersMessage)
->setParameter('%filter_label', $markup_filter->getLabel())
->setParameter('%filter_plugin_id', $markup_filter->getPluginId())
->addViolation();
}
}
}
/**
* Checks that fundamental CKEditor 5 plugins' HTML tags are allowed.
*
* @param \Drupal\filter\FilterFormatInterface $text_format
* The text format to validate.
* @param \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraint $constraint
* The constraint to validate.
*/
private function checkHtmlRestrictionsAreCompatible(FilterFormatInterface $text_format, FundamentalCompatibilityConstraint $constraint) : void {
$minimum_tags = array_keys($this->pluginManager->getProvidedElements(self::FUNDAMENTAL_CKEDITOR5_PLUGINS));
$html_restrictions = $text_format->getHtmlRestrictions();
$forbidden_minimum_tags = isset($html_restrictions['forbidden_tags'])
? array_diff($minimum_tags, $html_restrictions['forbidden_tags'])
: [];
if (!empty($forbidden_minimum_tags)) {
$offending_filter = static::findHtmlRestrictorFilterForbiddingTags($text_format, $minimum_tags);
$this->context->buildViolation($constraint->forbiddenElementsMessage)
->setParameter('%filter_label', $offending_filter->getLabel())
->setParameter('%filter_plugin_id', $offending_filter->getPluginId())
->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);
$this->context->buildViolation($constraint->nonAllowedElementsMessage)
->setParameter('%filter_label', $offending_filter->getLabel())
->setParameter('%filter_plugin_id', $offending_filter->getPluginId())
->addViolation();
}
}
/**
* Checks the HTML restrictions match the enabled CKEditor 5 plugins' output.
*
* @param \Drupal\filter\FilterFormatInterface $text_format
* The text format to validate.
* @param \Drupal\editor\EditorInterface $text_editor
* The text editor to validate.
* @param \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraint $constraint
* The constraint to validate.
*/
private function checkHtmlRestrictionsMatch(FilterFormatInterface $text_format, EditorInterface $text_editor, FundamentalCompatibilityConstraint $constraint) : void {
$html_restrictor_filters = static::getFiltersInFormatOfType(
$text_format,
FilterInterface::TYPE_HTML_RESTRICTOR
);
$enabled_plugins = array_keys($this->pluginManager->getEnabledDefinitions($text_editor));
$provided = $this->pluginManager->getProvidedElements($enabled_plugins);
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.
continue;
}
$allowed = $restrictions['allowed'];
// @todo Validate attributes allowed or forbidden on all elements.
if (isset($allowed['*'])) {
unset($allowed['*']);
}
if ($diff_allowed = DiffArray::diffAssocRecursive($allowed, $provided)) {
$this->context->buildViolation($constraint->notSupportedElementsMessage)
->setParameter('@list', $this->pluginManager->getReadableElements($provided))
->setParameter('@diff', $this->pluginManager->getReadableElements($diff_allowed))
->atPath("filters.$filter_plugin_id")
->addViolation();
}
elseif ($diff_elements = DiffArray::diffAssocRecursive($provided, $allowed)) {
$this->context->buildViolation($constraint->missingElementsMessage)
->setParameter('@list', $this->pluginManager->getReadableElements($provided))
->setParameter('@diff', $this->pluginManager->getReadableElements($diff_elements))
->atPath("filters.$filter_plugin_id")
->addViolation();
}
}
}
/**
* Gets the filters of the given type in this text format.
*
* @param \Drupal\filter\FilterFormatInterface $text_format
* A text format whose filters to get.
* @param int $filter_type
* One of FilterInterface::TYPE_*.
* @param callable|null $extra_requirements
* An optional callable that can check a filter of this type for additional
* conditions to be met. Must return TRUE when it meets the conditions,
* FALSE otherwise.
*
* @return \Drupal\filter\Plugin\FilterInterface[]
* The matched filter plugins.
*/
private static function getFiltersInFormatOfType(FilterFormatInterface $text_format, int $filter_type, callable $extra_requirements = NULL) : array {
assert(in_array($filter_type, [
FilterInterface::TYPE_MARKUP_LANGUAGE,
FilterInterface::TYPE_HTML_RESTRICTOR,
FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE,
FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE,
]));
return array_filter($text_format->filters()->getAll(), function (FilterInterface $filter) use ($filter_type, $extra_requirements) {
if (!$filter->status) {
return FALSE;
}
if ($filter->getType() === $filter_type && ($extra_requirements === NULL || $extra_requirements($filter))) {
return TRUE;
}
return FALSE;
});
}
/**
* Analyzes a text format to find the filter not allowing required tags.
*
* @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.
*
* @return \Drupal\filter\Plugin\FilterInterface
* The filter plugin instance not allowing the required tags.
*
* @throws \InvalidArgumentException
*/
private static function findHtmlRestrictorFilterForbiddingTags(FilterFormatInterface $text_format, array $required_tags) : FilterInterface {
// Get HTML restrictor filters that actually restrict HTML.
$filters = static::getFiltersInFormatOfType(
$text_format,
FilterInterface::TYPE_HTML_RESTRICTOR,
function (FilterInterface $filter) {
return $filter->getHTMLRestrictions() !== FALSE;
}
);
foreach ($filters as $filter) {
$restrictions = $filter->getHTMLRestrictions();
// @todo Fix \Drupal\filter_test\Plugin\Filter\FilterTestRestrictTagsAndAttributes::getHTMLRestrictions(), whose computed value for forbidden_tags does not comply with the API.
if (array_keys($restrictions['forbidden_tags']) != range(0, count($restrictions['forbidden_tags']))) {
$restrictions['forbidden_tags'] = array_keys($restrictions['forbidden_tags']);
}
if (isset($restrictions['forbidden_tags']) && !empty(array_intersect($required_tags, $restrictions['forbidden_tags']))) {
return $filter;
}
}
throw new \InvalidArgumentException('This text format does not have a "tags forbidden" restriction that includes the required tags.');
}
/**
* Analyzes a text format to find the filter not allowing required tags.
*
* @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.
*
* @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 {
// Get HTML restrictor filters that actually restrict HTML.
$filters = static::getFiltersInFormatOfType(
$text_format,
FilterInterface::TYPE_HTML_RESTRICTOR,
function (FilterInterface $filter) {
return $filter->getHTMLRestrictions() !== FALSE;
}
);
foreach ($filters as $filter) {
$restrictions = $filter->getHTMLRestrictions();
if (isset($restrictions['allowed']) && !empty(array_diff($required_tags, array_keys($restrictions['allowed'])))) {
return $filter;
}
}
throw new \InvalidArgumentException('This text format does not have a "tags allowed" restriction that excludes the required tags.');
}
}
