Issue #3231328 by Wim Leers, nod_: SmartDefaultSettings should select the CKE5 plugin that minimizes creation of HTML restriction supersets

(cherry picked from commit 4d02e19fec)
merge-requests/1862/merge
Lauri Eskola 2022-03-18 14:19:08 +02:00
parent 53cd5e46dc
commit a012320a7b
No known key found for this signature in database
GPG Key ID: 382FC0F5B0DF53F8
7 changed files with 686 additions and 165 deletions

View File

@ -357,6 +357,9 @@ final class HTMLRestrictions {
// Omit the broad wildcard addition that FilterHtml::getHTMLRestrictions()
// always sets; it is specific to how FilterHTML works and irrelevant here.
unset($allowed_elements['*']);
// @see \Drupal\filter\Plugin\Filter\FilterHtml::getHTMLRestrictions()
// @todo remove this in https://www.drupal.org/project/drupal/issues/3226368
unset($allowed_elements['__zqh6vxfbk3cg__']);
// Postprocess tag wildcards: convert `<__preprocessed-wildcard-block__>` to
// `<$block>`.

View File

@ -10,6 +10,7 @@ use Drupal\ckeditor\CKEditorPluginManager;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\editor\EditorInterface;
@ -129,37 +130,36 @@ final class SmartDefaultSettings {
$editor->setImageUploadSettings($old_editor->getImageUploadSettings());
}
// First, add toolbar items based on HTML tags.
// Add toolbar items based on HTML tags and attributes.
// NOTE: Helper updates $editor->settings by reference and returns info for the message.
$result = $this->addToolbarItemsToMatchHtmlTagsInFormat($text_format, $editor);
$result = $this->addToolbarItemsToMatchHtmlElementsInFormat($text_format, $editor);
if ($result !== NULL) {
[$enabling_message_content, $unsupported] = $result;
[$enabling_message_content, $enabled_for_attributes_message_content, $missing] = $result;
// Distinguish between unsupported elements covering only tags or not.
$missing_attributes = new HTMLRestrictions(array_filter($missing->getAllowedElements()));
$unsupported = $missing->diff($missing_attributes);
if ($enabling_message_content) {
$messages[] = $this->t('The following plugins were enabled to support tags that are allowed by this text format: %enabling_message_content.',
['%enabling_message_content' => $enabling_message_content],
);
}
// Warn user about unsupported tags.
if (!empty($unsupported)) {
if (!$unsupported->isEmpty()) {
$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 <em>Manually editable HTML tags</em>: @unsupported_string.", [
'@unsupported_string' => $unsupported->toFilterHtmlAllowedTagsString(),
]);
}
}
// Next, add more toolbar items to try to also support attributes on already
// supported tags that have still unsupported attributes.
$result = $this->addToolbarItemsToMatchHtmlAttributesInFormat($text_format, $editor);
if ($result !== NULL) {
[$enabled_for_attributes_message_content, $missing_attributes] = $result;
if ($enabled_for_attributes_message_content) {
$messages[] = $this->t('The following plugins were enabled to support specific attributes that are allowed by this text format: %enabled_for_attributes_message_content.',
['%enabled_for_attributes_message_content' => $enabled_for_attributes_message_content],
);
}
// Warn user about supported tags but missing attributes.
if ($missing_attributes) {
if (!$missing_attributes->isEmpty()) {
$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 <em>Manually editable HTML tags</em>: @missing_attributes.", [
'@missing_attributes' => $missing_attributes->toFilterHtmlAllowedTagsString(),
@ -329,157 +329,344 @@ final class SmartDefaultSettings {
}
/**
* Adds CKEditor 5 toolbar items to match the format's HTML tags.
* Computes net new needed elements when considering adding the given plugin.
*
* @param \Drupal\filter\FilterFormatInterface $format
* The text format for which to compute smart default settings.
* @param \Drupal\editor\EditorInterface $editor
* The text editor config entity to update.
* @param \Drupal\ckeditor5\HTMLRestrictions $baseline
* The set of HTML restrictions already supported.
* @param \Drupal\ckeditor5\HTMLRestrictions $needed
* The set of HTML restrictions that are needed, that is: in addition to
* $baseline.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $added_plugin
* The CKEditor 5 plugin that is being evaluated to check if it would meet
* some of the needs.
*
* @return array|null
* 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 elements, in an HTMLRestrictions value object
* @return array
* An array containing two values:
* - a set of HTML restrictions that indicates the net new additions that
* are needed
* - a set of HTML restrictions that indicates the surplus additions (these
* are elements that were not needed, but are added by this plugin)
*/
private function addToolbarItemsToMatchHtmlTagsInFormat(FilterFormatInterface $format, EditorInterface $editor): ?array {
$html_restrictions_needed_elements = $format->getHtmlRestrictions();
if ($html_restrictions_needed_elements === FALSE) {
return NULL;
}
// Add all buttons until we match or exceed the current text format
// restrictions.
$enabled_plugins = array_keys($this->pluginManager->getEnabledDefinitions($editor));
$provided_elements = $this->pluginManager->getProvidedElements($enabled_plugins);
// Automatically add the plugins that add support for the tags we want this
// CKEditor 5 instance to support.
$missing_tags = array_diff(array_keys($html_restrictions_needed_elements['allowed']), array_keys($provided_elements));
$to_add = [];
$unsupported = [];
foreach ($missing_tags as $tag) {
$id = $this->pluginManager->findPluginSupportingElement($tag);
if ($id) {
$to_add[$tag] = $id;
}
// Add any tag that isn't the "star protector" tag to the array of
// unsupported tags.
// @see the $star_protector variable in
// \Drupal\filter\Plugin\Filter\FilterHtml::getHTMLRestrictions
// @todo this can be an 'else' with no conditions after
// https://www.drupal.org/project/drupal/issues/3226368
elseif ($tag !== '__zqh6vxfbk3cg__') {
$unsupported[$tag] = $html_restrictions_needed_elements['allowed'][$tag];
}
}
$enabling_message_content = '';
$enabling_message_prep = [];
foreach ($to_add as $tag_name => $plugin_name) {
$enabling_message_prep[$plugin_name][] = $tag_name;
}
$editor_settings_to_update = $editor->getSettings();
$new_group_created = FALSE;
foreach ($enabling_message_prep as $plugin_id => $tag_names) {
$label = $this->pluginManager->getDefinition($plugin_id)->label();
$tags = array_reduce($tag_names, function ($carry, $item) {
return $carry . "<$item>";
});
$enabling_message_content .= "$label (for tags: $tags) ";
$definition = $this->pluginManager->getDefinition($plugin_id);
if ($definition->hasToolbarItems()) {
if (!$new_group_created) {
$editor_settings_to_update['toolbar']['items'][] = '|';
$new_group_created = TRUE;
}
$editor_settings_to_update['toolbar']['items'] = array_merge($editor_settings_to_update['toolbar']['items'], array_keys($definition->getToolbarItems()));
}
}
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, new HTMLRestrictions($unsupported)];
}
else {
return [NULL, new HTMLRestrictions($unsupported)];
}
private static function computeNetNewElementsForPlugin(HTMLRestrictions $baseline, HTMLRestrictions $needed, CKEditor5PluginDefinition $added_plugin): array {
$plugin_support = HTMLRestrictions::fromString(implode(' ', $added_plugin->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 = $baseline->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($needed)->diff($baseline);
// But … we may compute too many.
$surplus_additions = $potential_future->diff($needed)->diff($baseline);
return [$net_new, $surplus_additions];
}
/**
* Adds CKEditor 5 toolbar items to match the format's HTML attributes.
* Computes a score for the given surplus compared to the given need.
*
* @param \Drupal\filter\FilterFormatInterface $format
* The text format for which to compute smart default settings.
* @param \Drupal\editor\EditorInterface $editor
* The text editor config entity to update.
* @param \Drupal\ckeditor5\HTMLRestrictions $surplus
* A surplus compared to what is needed.
* @param \Drupal\ckeditor5\HTMLRestrictions $needed
* Exactly what is needed.
*
* @return array|null
* 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 elements, in an HTMLRestrictions value object
* @return int
* A surplus score. Lower is better. Scores are a positive integer.
*
* @see https://www.drupal.org/project/drupal/issues/3231328#comment-14444987
*/
private function addToolbarItemsToMatchHtmlAttributesInFormat(FilterFormatInterface $format, EditorInterface $editor): ?array {
$html_restrictions_needed_elements = $format->getHtmlRestrictions();
if ($html_restrictions_needed_elements === FALSE) {
return NULL;
private static function computeSurplusScore(HTMLRestrictions $surplus, HTMLRestrictions $needed): int {
// Compute a score for surplus elements, while taking into account how much
// impact each surplus element has:
$surplus_score = 0;
foreach ($surplus->getAllowedElements() as $tag_name => $attributes_config) {
// 10^6 per surplus tag.
if (!isset($needed->getAllowedElements()[$tag_name])) {
$surplus_score += pow(10, 6);
}
// 10^5 per surplus "any attributes allowed".
if ($attributes_config === TRUE) {
$surplus_score += pow(10, 5);
}
if (!is_array($attributes_config)) {
continue;
}
foreach ($attributes_config as $attribute_name => $attribute_config) {
// 10^4 per surplus wildcard attribute.
if (strpos($attribute_name, '*') !== FALSE) {
$surplus_score += pow(10, 4);
}
// 10^3 per surplus attribute.
else {
$surplus_score += pow(10, 3);
}
// 10^2 per surplus "any attribute values allowed".
if ($attribute_config === TRUE) {
$surplus_score += pow(10, 2);
}
if (!is_array($attribute_config)) {
continue;
}
foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
// 10^1 per surplus wildcard attribute value.
if (strpos($allowed_attribute_value, '*') !== FALSE) {
$surplus_score += pow(10, 1);
}
// 10^0 per surplus attribute value.
else {
$surplus_score += pow(10, 0);
}
}
}
}
return $surplus_score;
}
$enabled_plugins = array_keys($this->pluginManager->getEnabledDefinitions($editor));
$provided_elements = $this->pluginManager->getProvidedElements($enabled_plugins);
$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);
/**
* Finds candidates for the still needed restrictions among disabled plugins.
*
* @param \Drupal\ckeditor5\HTMLRestrictions $provided
* The already provided HTML restrictions, thanks to already enabled
* CKEditor 5 plugins.
* @param \Drupal\ckeditor5\HTMLRestrictions $still_needed
* The still needed HTML restrictions, unmet by the already enabled CKEditor
* 5 plugins.
* @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[] $disabled_plugin_definitions
* The list of not yet enabled CKEditor 5 plugin definitions, amongst which
* candidates must be found.
*
* @return array
* A nested array with a tree structure covering:
* 1. tag name
* 2. concrete attribute name, `-attribute-none-` (meaning no attributes
* allowed on this tag) or `-attribute-any-` (meaning any attribute
* allowed on this tag).
* 3. (optional) attribute value (if concrete attribute name in previous
* level), `TRUE` or `FALSE`
* 4. (optional) attribute value restriction
* 5. candidate CKEditor 5 plugin ID for the HTML elements in the hierarchy
* and the surplus score as the value. In other words: the leaf of this is
* always a leaf, and a selected CKEditor 5 plugin ID is always the parent
* of a leaf.
*/
private static function getCandidates(HTMLRestrictions $provided, HTMLRestrictions $still_needed, array $disabled_plugin_definitions): array {
$plugin_candidates = [];
if (!$still_needed->isEmpty()) {
$all_plugins_definitions = $this->pluginManager->getDefinitions();
foreach ($all_plugins_definitions as $plugin_id => $definition) {
foreach ($disabled_plugin_definitions as $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, 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 (!$definition->hasConditions() && $definition->hasElements()) {
[$net_new, $surplus_additions] = self::computeNetNewElementsForPlugin($provided, $still_needed, $definition);
if (!$net_new->isEmpty()) {
$plugin_id = $definition->id();
$surplus_score = static::computeSurplusScore($surplus_additions, $still_needed);
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;
// Non-specific attribute restrictions: `FALSE` or `TRUE`.
// TRICKY: PHP does not support boolean array keys, so map these
// to a string. The string must not be a valid attribute name, so
// use a leading and trailing dash.
if (!is_array($attributes_config)) {
$non_specific_attribute = $attributes_config ? '-attributes-any-' : '-attributes-none-';
$plugin_candidates[$tag_name][$non_specific_attribute][$plugin_id] = $surplus_score;
continue;
}
// With specific attribute restrictions: array.
foreach ($attributes_config as $attribute_name => $attribute_config) {
if (!is_array($attribute_config)) {
$plugin_candidates[$tag_name][$attribute_name][$attribute_config][$plugin_id] = $surplus_score;
}
else {
foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
$plugin_candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config][$plugin_id] = $surplus_score;
}
}
}
// If this plugin supports unneeded attributes, it still makes a
// valid candidate for supporting the HTML tag.
$plugin_candidates[$tag_name]['-attributes-none-'][$plugin_id] = $surplus_score;
}
// Fewer attributes are still needed.
$still_needed = $still_needed->diff($net_new);
}
}
}
}
return $plugin_candidates;
}
/**
* Selects best candidate for each of the still needed restrictions.
*
* @param array $candidates
* The output of ::getCandidates().
* @param \Drupal\ckeditor5\HTMLRestrictions $still_needed
* The still needed HTML restrictions, unmet by the already enabled CKEditor
* 5 plugins.
* @param string[] $already_supported_tags
* A list of already supported HTML tags, necessary to select the best
* matching candidate for elements still needed in $still_needed.
*
* @return array
* A nested array with a tree structure, with each key a selected CKEditor 5
* plugin ID and its values expressing the reason it was enabled.
*/
private static function selectCandidate(array $candidates, HTMLRestrictions $still_needed, array $already_supported_tags): array {
assert(Inspector::assertAllStrings($already_supported_tags));
// Make a selection in the candidates: minimize the surplus count, to
// avoid generating surplus additions whenever possible.
$selected_plugins = [];
foreach ($still_needed->getAllowedElements() as $tag_name => $attributes_config) {
if (!isset($candidates[$tag_name])) {
// Sadly no plugin found for this tag.
continue;
}
// Non-specific attribute restrictions for tag.
if (is_bool($attributes_config)) {
$key = $attributes_config ? '-attributes-any-' : '-attributes-none-';
if (!isset($candidates[$tag_name][$key])) {
// Sadly no plugin found for this tag + unspecific attribute.
continue;
}
asort($candidates[$tag_name][$key]);
$selected_plugin_id = array_keys($candidates[$tag_name][$key])[0];
$selected_plugins[$selected_plugin_id][$key][$tag_name] = NULL;
continue;
}
// Specific attribute restrictions for tag.
foreach ($attributes_config as $attribute_name => $attribute_config) {
if (!isset($candidates[$tag_name][$attribute_name])) {
// Sadly no plugin found for this tag + attribute.
continue;
}
if (!is_array($attribute_config)) {
if (!isset($candidates[$tag_name][$attribute_name][$attribute_config])) {
// Sadly no plugin found for this tag + attribute + config.
continue;
}
asort($candidates[$tag_name][$attribute_name][$attribute_config]);
$selected_plugin_id = array_keys($candidates[$tag_name][$attribute_name][$attribute_config])[0];
$selected_plugins[$selected_plugin_id][$attribute_name][$tag_name] = $attribute_config;
continue;
}
else {
foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
if (!isset($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config])) {
// Sadly no plugin found for this tag + attr + value + config.
continue;
}
asort($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config]);
$selected_plugin_id = array_keys($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config])[0];
$selected_plugins[$selected_plugin_id][$attribute_name][$tag_name][$allowed_attribute_value] = $allowed_attribute_value_config;
continue;
}
}
}
// If we got to this point, no exact match was found. But selecting a
// plugin to support the tag at all (when it is not yet supported) is
// crucial to meet the user's expectations.
// For example: when `<blockquote cite>` is needed, select at least the
// plugin that can support `<blockquote>`, then only the `cite` attribute
// needs to be made possible using the `SourceEditing` plugin.
if (!in_array($tag_name, $already_supported_tags, TRUE) && isset($candidates[$tag_name]['-attributes-none-'])) {
asort($candidates[$tag_name]['-attributes-none-']);
$selected_plugin_id = array_keys($candidates[$tag_name]['-attributes-none-'])[0];
$selected_plugins[$selected_plugin_id]['-attributes-none-'][$tag_name] = NULL;
}
}
// The above selects all exact matches. It's possible the same plugin is
// selected for multiple reasons: for supporting the tag at all, but also
// for supporting more attributes on the tag. Whenever that scenario
// occurs, keep only the "tag" reason, since that is the most relevant one
// for the end user. Otherwise a single plugin being selected (and enabled)
// could generate multiple messages, which would be confusing and
// overwhelming for the user.
// For example: when `<a href>` is needed, supporting `<a>` is more
// relevant to be informed about as an end user than the plugin also being
// enabled to support the `href` attribute.
foreach ($selected_plugins as $selected_plugin_id => $reason) {
if (count($reason) > 1 && isset($reason['-attributes-none-'])) {
$selected_plugins[$selected_plugin_id] = array_intersect_key($reason, ['-attributes-none-' => TRUE]);
}
}
return $selected_plugins;
}
/**
* Adds CKEditor 5 toolbar items to match the format's HTML elements.
*
* @param \Drupal\filter\FilterFormatInterface $format
* The text format for which to compute smart default settings.
* @param \Drupal\editor\EditorInterface $editor
* The text editor config entity to update.
*
* @return array|null
* NULL when nothing happened, otherwise an array with three 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. a description (for use in a message) of which CKEditor 5 plugins were
* enabled to match the HTML attributes allowed by the text format.
* 3. the unsupported elements, in an HTMLRestrictions value object
*/
private function addToolbarItemsToMatchHtmlElementsInFormat(FilterFormatInterface $format, EditorInterface $editor): ?array {
$html_restrictions_needed_elements = $format->getHtmlRestrictions();
if ($html_restrictions_needed_elements === FALSE) {
return NULL;
}
$all_definitions = $this->pluginManager->getDefinitions();
$enabled_definitions = $this->pluginManager->getEnabledDefinitions($editor);
$disabled_definitions = array_diff_key($all_definitions, $enabled_definitions);
$enabled_plugins = array_keys($enabled_definitions);
$provided_elements = $this->pluginManager->getProvidedElements($enabled_plugins);
$provided = new HTMLRestrictions($provided_elements);
$still_needed = HTMLRestrictions::fromTextFormat($format)->diff($provided);
if (!$still_needed->isEmpty()) {
$plugin_candidates = self::getCandidates($provided, $still_needed, $disabled_definitions);
$selected_plugins = self::selectCandidate($plugin_candidates, $still_needed, array_keys($provided->getAllowedElements()));
// 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.
if (!empty($plugins_to_enable_to_support_attribute_config)) {
if (!empty($selected_plugins)) {
$enabled_for_tags_message_content = '';
$enabled_for_attributes_message_content = '';
$editor_settings_to_update = $editor->getSettings();
foreach ($plugins_to_enable_to_support_attribute_config as $plugin_id => $reason_why_enabled) {
// Create new group for all the added toolbar items.
$editor_settings_to_update['toolbar']['items'][] = '|';
foreach ($selected_plugins as $plugin_id => $reason_why_enabled) {
$plugin_definition = $this->pluginManager->getDefinition($plugin_id);
$label = $plugin_definition->label();
if ($plugin_definition->hasToolbarItems()) {
[$net_new] = self::computeNetNewElementsForPlugin($provided, $still_needed, $plugin_definition);
$editor_settings_to_update['toolbar']['items'] = array_merge($editor_settings_to_update['toolbar']['items'], array_keys($plugin_definition->getToolbarItems()));
foreach ($reason_why_enabled as $attribute_name => $attribute_config) {
// Plugin was selected for tag.
if (in_array($attribute_name, ['-attributes-none-', '-attributes-any-'], TRUE)) {
$label = $plugin_definition->label();
$tags = array_reduce(array_keys($net_new->getAllowedElements()), function ($carry, $item) {
return $carry . "<$item>";
});
$enabled_for_tags_message_content .= "$label (for tags: $tags) ";
// This plugin does not add attributes: continue to next plugin.
continue;
}
// Plugin was selected for attribute.
$enabled_for_attributes_message_content .= "$label (";
foreach ($attribute_config as $tag_name => $attribute_value_config) {
$enabled_for_attributes_message_content .= " for tag: <$tag_name> to support: $attribute_name";
@ -492,18 +679,23 @@ final class SmartDefaultSettings {
}
}
}
// Fewer attributes are still needed.
$still_needed = $still_needed->diff($net_new);
}
}
$editor->setSettings($editor_settings_to_update);
// Some plugins enabled, maybe some missing attributes.
// Some plugins enabled, maybe some missing tags or attributes.
return [
substr($enabled_for_tags_message_content, 0, -1),
substr($enabled_for_attributes_message_content, 0, -2),
$still_needed,
];
}
else {
// No plugins enabled, maybe some missing attributes.
// No plugins enabled, maybe some missing tags or attributes.
return [
NULL,
NULL,
$still_needed,
];

View File

@ -12,3 +12,16 @@ ckeditor5_plugin_conditions_test_plugins_condition:
- ckeditor5_table
elements:
- <foo>
# @todo remove in https://www.drupal.org/project/drupal/issues/3263384
ckeditor5_plugin_conditions_test_codeBlock_until_3263384_lands:
ckeditor5:
plugins: []
drupal:
label: TEST until #3263384 lands
toolbar_items:
codeBlockPlaceholder:
label: TODO
elements:
- <pre>
- <code class="lang-*">

View File

@ -57,6 +57,8 @@ class SmartDefaultSettingsTest extends KernelTestBase {
'media',
'media_library',
'views',
// @todo Remove in https://www.drupal.org/project/drupal/issues/3263384
'ckeditor5_plugin_conditions_test',
];
/**
@ -113,6 +115,17 @@ class SmartDefaultSettingsTest extends KernelTestBase {
Yaml::parseFile('core/profiles/standard/config/install/editor.editor.basic_html.yml')
)->save();
$basic_html_format_with_pre = $basic_html_format;
$basic_html_format_with_pre['name'] .= ' (with <pre>)';
$basic_html_format_with_pre['format'] = 'basic_html_with_pre';
NestedArray::setValue($basic_html_format_with_pre, $allowed_html_parents, $current_value . ' <pre>');
FilterFormat::create($basic_html_format_with_pre)->save();
Editor::create(
['format' => 'basic_html_with_pre']
+
Yaml::parseFile('core/profiles/standard/config/install/editor.editor.basic_html.yml')
)->save();
$new_value = str_replace('<p>', '<p class="text-align-center text-align-justify">', $current_value);
$basic_html_format_with_alignable_p = $basic_html_format;
$basic_html_format_with_alignable_p['name'] .= ' (with alignable paragraph support)';
@ -548,16 +561,39 @@ class SmartDefaultSettingsTest extends KernelTestBase {
),
];
yield "basic_html_with_pre can be switched to CKEditor 5 without problems, heading configuration computed automatically" => [
'format_id' => 'basic_html_with_pre',
'filters_to_drop' => $basic_html_test_case['filters_to_drop'],
'expected_ckeditor5_settings' => [
'toolbar' => [
'items' => array_merge(
$basic_html_test_case['expected_ckeditor5_settings']['toolbar']['items'],
['codeBlockPlaceholder'],
),
],
'plugins' => $basic_html_test_case['expected_ckeditor5_settings']['plugins'],
],
'expected_superset' => '<code class="lang-*"> ' . $basic_html_test_case['expected_superset'],
'expected_fundamental_compatibility_violations' => $basic_html_test_case['expected_fundamental_compatibility_violations'],
'expected_messages' => [
'The following plugins were enabled to support tags that are allowed by this text format: <em class="placeholder">Code (for tags: &lt;code&gt;) Language (for tags: &lt;span&gt;) TEST until (for tags: &lt;pre&gt;)</em>.',
$basic_html_test_case['expected_messages'][1],
'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;.',
],
];
yield "basic_html_with_alignable_p can be switched to CKEditor 5 without problems, align buttons added automatically" => [
'format_id' => 'basic_html_with_alignable_p',
'filters_to_drop' => $basic_html_test_case['filters_to_drop'],
'expected_ckeditor5_settings' => [
'toolbar' => [
'items' => array_merge(
$basic_html_test_case['expected_ckeditor5_settings']['toolbar']['items'],
array_slice($basic_html_test_case['expected_ckeditor5_settings']['toolbar']['items'], 0, -1),
[
'alignment',
]
'alignment:center',
'alignment:justify',
],
array_slice($basic_html_test_case['expected_ckeditor5_settings']['toolbar']['items'], -1)
),
],
'plugins' => $basic_html_test_case['expected_ckeditor5_settings']['plugins'],
@ -565,31 +601,27 @@ class SmartDefaultSettingsTest extends KernelTestBase {
'expected_superset' => implode(' ', [
// Note that aligning left and right is being added, on top of what the
// original format allowed: center and justify.
// @todo Improve in https://www.drupal.org/project/drupal/issues/3231328
'<p class="text-align-left text-align-right">',
// Note that aligning left/center/right/justify is possible on *all*
// allowed block-level HTML5 tags.
// @todo When https://www.drupal.org/project/ckeditor5/issues/3231328
// lands, only the center/justify classes will be added.
// @todo When https://www.drupal.org/project/drupal/issues/3259367
// lands, none of the tags below should appear.
'<h2 class="text-align-left text-align-center text-align-right text-align-justify">',
'<h3 class="text-align-left text-align-center text-align-right text-align-justify">',
'<h4 class="text-align-left text-align-center text-align-right text-align-justify">',
'<h5 class="text-align-left text-align-center text-align-right text-align-justify">',
'<h6 class="text-align-left text-align-center text-align-right text-align-justify">',
'<dl class="text-align-left text-align-center text-align-right text-align-justify">',
'<dd class="text-align-left text-align-center text-align-right text-align-justify">',
'<blockquote class="text-align-left text-align-center text-align-right text-align-justify">',
'<ul class="text-align-left text-align-center text-align-right text-align-justify">',
'<ol class="text-align-left text-align-center text-align-right text-align-justify">',
'<h2 class="text-align-center text-align-justify">',
'<h3 class="text-align-center text-align-justify">',
'<h4 class="text-align-center text-align-justify">',
'<h5 class="text-align-center text-align-justify">',
'<h6 class="text-align-center text-align-justify">',
'<dl class="text-align-center text-align-justify">',
'<dd class="text-align-center text-align-justify">',
'<blockquote class="text-align-center text-align-justify">',
'<ul class="text-align-center text-align-justify">',
'<ol class="text-align-center text-align-justify">',
$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'],
[
'The following plugins were enabled to support specific attributes that are allowed by this text format: <em class="placeholder">Alignment ( for tag: &lt;p&gt; to support: class with value(s): text-align-center, text-align-justify)</em>.',
'The following plugins were enabled to support specific attributes that are allowed by this text format: <em class="placeholder">Align center ( for tag: &lt;p&gt; to support: class with value(s): text-align-center), Justify ( for tag: &lt;p&gt; to support: class with value(s): text-align-justify)</em>.',
'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;.',
]),
];

View File

@ -0,0 +1,30 @@
<?php
namespace Drupal\Tests\ckeditor5\Traits;
/**
* Provides methods to test protected/private methods in unit tests.
*
* @internal
*/
trait PrivateMethodUnitTestTrait {
/**
* Gets a protected/private method to test.
*
* @param string $fqcn
* A fully qualified classname.
* @param string $name
* The method name.
*
* @return \ReflectionMethod
* The accessible method.
*/
protected static function getMethod(string $fqcn, string $name): \ReflectionMethod {
$class = new \ReflectionClass($fqcn);
$method = $class->getMethod($name);
$method->setAccessible(TRUE);
return $method;
}
}

View File

@ -5,6 +5,7 @@ declare(strict_types = 1);
namespace Drupal\Tests\ckeditor5\Unit;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\Tests\ckeditor5\Traits\PrivateMethodUnitTestTrait;
use Drupal\Tests\UnitTestCase;
/**
@ -14,6 +15,8 @@ use Drupal\Tests\UnitTestCase;
*/
class CKEditor5Test extends UnitTestCase {
use PrivateMethodUnitTestTrait;
/**
* Simulated CKEditor5::buildConfigurationForm() form structure.
*
@ -36,28 +39,12 @@ class CKEditor5Test extends UnitTestCase {
],
];
/**
* Gets a protected/private method to test.
*
* @param string $name
* The method name.
*
* @return \ReflectionMethod
* The accessible method.
*/
protected static function getMethod(string $name): \ReflectionMethod {
$class = new \ReflectionClass(CKEditor5::class);
$method = $class->getMethod($name);
$method->setAccessible(TRUE);
return $method;
}
/**
* @covers \Drupal\ckeditor5\Plugin\Editor\CKEditor5::mapViolationPropertyPathsToFormNames
* @dataProvider providerPathsToFormNames
*/
public function testPathsToFormNames(string $property_path, string $expected_form_item_name, bool $expect_exception = FALSE): void {
$mapMethod = self::getMethod('mapViolationPropertyPathsToFormNames');
$mapMethod = self::getMethod(CKEditor5::class, 'mapViolationPropertyPathsToFormNames');
if ($expect_exception) {
$this->expectExceptionMessage('assert($shifted === \'settings\')');
}

View File

@ -0,0 +1,264 @@
<?php
declare(strict_types = 1);
namespace Drupal\Tests\ckeditor5\Unit;
use Drupal\ckeditor5\Annotation\CKEditor5Plugin;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\ckeditor5\SmartDefaultSettings;
use Drupal\Component\Utility\NestedArray;
use Drupal\Tests\ckeditor5\Traits\PrivateMethodUnitTestTrait;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\ckeditor5\SmartDefaultSettings
* @group ckeditor5
*/
class SmartDefaultSettingsTest extends UnitTestCase {
use PrivateMethodUnitTestTrait;
/**
* @covers ::computeSurplusScore
* @dataProvider providerSurplusScore
*/
public function testSurplusScore(HTMLRestrictions $surplus, HTMLRestrictions $needed, int $expected): void {
$method = self::getMethod(SmartDefaultSettings::class, 'computeSurplusScore');
$this->assertSame($expected, $method->invoke(NULL, $surplus, $needed));
}
/**
* Data provider for testing computeSurplusScore().
*
* @return \Generator
*/
public function providerSurplusScore(): \Generator {
$needed = new HTMLRestrictions(['code' => FALSE]);
yield 'surplus: 1 tag, 1 attribute, 1 attribute with wildcard restriction' => [
HTMLRestrictions::fromString('<pre> <code class="language-*">'),
$needed,
1001010,
];
yield 'surplus: 1 tag, 1 attribute, 2 allowed attribute values' => [
HTMLRestrictions::fromString('<code class="language-php language-js">'),
$needed,
1002,
];
yield 'surplus: 2 attributes, 4 allowed attribute values' => [
// cSpell:disable-next-line
HTMLRestrictions::fromString('<code class="language-php language-js" data-library="highlightjs something">'),
$needed,
2004,
];
yield 'surplus: 1 any attribute allowed' => [
HTMLRestrictions::fromString('<code *>'),
$needed,
100000,
];
yield 'surplus: 1 attribute, 1 attribute any value allowed' => [
HTMLRestrictions::fromString('<code class>'),
$needed,
1100,
];
yield 'surplus: 1 tag, 2 wildcard attributes, 2 attributes, 3 attributes any value allowed, 1 wildcard allowed attribute value ' => [
HTMLRestrictions::fromString('<pre> <code data-config-* data-options-* data-highlight-library class="language-*">'),
$needed,
1022310,
];
}
/**
* @covers ::getCandidates
* @covers ::selectCandidate
* @dataProvider providerCandidates
*/
public function testCandidates(HTMLRestrictions $provided, HTMLRestrictions $still_needed, array $disabled_plugin_definitions, array $expected_candidates, array $expected_selection = []): void {
$get_candidates = self::getMethod(SmartDefaultSettings::class, 'getCandidates');
$this->assertSame($expected_candidates, $get_candidates->invoke(NULL, $provided, $still_needed, $disabled_plugin_definitions));
$select_candidate = self::getMethod(SmartDefaultSettings::class, 'selectCandidate');
$this->assertSame($expected_selection, $select_candidate->invoke(NULL, $expected_candidates, $still_needed, array_keys($provided->getAllowedElements())));
}
/**
* Data provider for testing getCandidates() and ::selectCandidate().
*
* @return \Generator
*/
public function providerCandidates(): \Generator {
$generate_definition = function (string $label_and_id, array $overrides): CKEditor5PluginDefinition {
$annotation = [
'provider' => 'test',
'id' => "test_$label_and_id",
'drupal' => ['label' => "$label_and_id"],
'ckeditor5' => ['plugins' => []],
];
foreach ($overrides as $path => $value) {
NestedArray::setValue($annotation, explode('.', $path), $value);
}
$annotation_instance = new CKEditor5Plugin($annotation);
$definition = $annotation_instance->get();
return $definition;
};
yield 'Tag needed, no match' => [
HTMLRestrictions::emptySet(),
HTMLRestrictions::fromString('<foo>'),
[
$generate_definition('foo', ['drupal.elements' => FALSE]),
],
[],
[],
];
yield 'Tag needed, single match without surplus' => [
HTMLRestrictions::emptySet(),
HTMLRestrictions::fromString('<foo>'),
[
$generate_definition('foo', ['drupal.elements' => ['<foo>']]),
],
[
'foo' => [
'-attributes-none-' => [
'test_foo' => 0,
],
],
],
// Perfect surplus score, but also only choice available.
['test_foo' => ['-attributes-none-' => ['foo' => NULL]]],
];
yield 'Tag needed, single match with surplus' => [
HTMLRestrictions::emptySet(),
HTMLRestrictions::fromString('<foo>'),
[
$generate_definition('foo', ['drupal.elements' => ['<foo bar baz>']]),
],
[
'foo' => [
'-attributes-none-' => [
'test_foo' => 2200,
],
],
],
// Not great surplus score, but only choice available.
['test_foo' => ['-attributes-none-' => ['foo' => NULL]]],
];
$various_foo_definitions = [
$generate_definition('all_attrs', ['drupal.elements' => ['<foo *>']]),
$generate_definition('attrs', ['drupal.elements' => ['<foo bar baz>']]),
$generate_definition('attr_values', ['drupal.elements' => ['<foo bar="a b">']]),
$generate_definition('plain', ['drupal.elements' => ['<foo>']]),
$generate_definition('tags', ['drupal.elements' => ['<foo>', '<bar>', '<baz>']]),
$generate_definition('tags_and_attrs', ['drupal.elements' => ['<foo bar baz>', '<bar>', '<baz>']]),
$generate_definition('tags_and_attr_values', ['drupal.elements' => ['<foo bar="a b" baz>', '<bar>', '<baz>']]),
];
yield 'Tag needed, multiple matches' => [
HTMLRestrictions::emptySet(),
HTMLRestrictions::fromString('<foo>'),
$various_foo_definitions,
[
'foo' => [
'-attributes-none-' => [
'test_all_attrs' => 100000,
'test_attrs' => 2200,
'test_attr_values' => 1002,
'test_plain' => 0,
'test_tags' => 2000000,
'test_tags_and_attrs' => 2002200,
'test_tags_and_attr_values' => 2002102,
],
],
],
// test_plain (elements: `<foo>`) has perfect surplus score.
['test_plain' => ['-attributes-none-' => ['foo' => NULL]]],
];
yield 'Attribute needed, multiple matches' => [
HTMLRestrictions::fromString('<foo>'),
HTMLRestrictions::fromString('<foo bar>'),
$various_foo_definitions,
[
'foo' => [
'bar' => [
// Because `<foo bar>` allowed.
TRUE => [
'test_all_attrs' => 100000,
// This will be selected.
'test_attrs' => 1100,
'test_tags_and_attrs' => 2001100,
],
// Because `<foo bar="a">` allowed.
'a' => [
TRUE => [
'test_attr_values' => 1002,
'test_tags_and_attr_values' => 2002102,
],
],
// Because `<foo bar="b">` allowed.
'b' => [
TRUE => [
'test_attr_values' => 1002,
'test_tags_and_attr_values' => 2002102,
],
],
],
// Note that `test_plain` and `test_tags` are absent.
'-attributes-none-' => [
'test_all_attrs' => 100000,
'test_attrs' => 1100,
'test_attr_values' => 1002,
'test_tags_and_attrs' => 2001100,
'test_tags_and_attr_values' => 2002102,
],
],
],
// test_attrs (elements: `<foo bar baz>`) has best surplus score, despite
// allowing one extra attribute and any value on that attribute.
['test_attrs' => ['bar' => ['foo' => TRUE]]],
];
yield 'Attribute value needed, multiple matches' => [
HTMLRestrictions::fromString('<foo>'),
HTMLRestrictions::fromString('<foo bar="b">'),
$various_foo_definitions,
[
'foo' => [
'bar' => [
'b' => [
TRUE => [
'test_all_attrs' => 100000,
'test_attrs' => 2200,
// This will be selected.
'test_attr_values' => 1001,
'test_tags_and_attrs' => 2002200,
'test_tags_and_attr_values' => 2002101,
],
],
],
// Note that `test_plain` and `test_tags` are absent.
'-attributes-none-' => [
'test_all_attrs' => 100000,
'test_attrs' => 2200,
'test_attr_values' => 1001,
'test_tags_and_attrs' => 2002200,
'test_tags_and_attr_values' => 2002101,
],
],
],
// test_attr_values (elements: `<foo bar="a b">`) has best surplus score,
// despite allowing one extra attribute value.
['test_attr_values' => ['bar' => ['foo' => ['b' => TRUE]]]],
];
}
}