Commit 34c7429e authored by benjifisher's avatar benjifisher Committed by heddn
Browse files

Issue #3042539 by benjifisher, alisonjo2786, marvil07, heddn: Apply styles configured for CKEditor

parent 0f1a598a
<?php
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Core\Config\ConfigFactory;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Apply Editor styles to configured elements.
*
* Replace HTML elements with elements and classes specified in the Styles menu
* of the WYSIWYG editor.
*
* Available configuration keys:
* - format: the text format to inspect for style options (optional,
* defaults to 'basic_html').
* - rules: an array of keyed arrays, with the following keys:
* - xpath: an XPath expression for the elements to replace.
* - style: the label of the item in the Styles menu to use.
* - depth: the number of parent elements to remove (optional, defaults to 0).
*
* Example:
*
* @code
* process:
* 'body/value':
* -
* plugin: dom
* method: import
* source: 'body/0/value'
* -
* plugin: dom_apply_styles
* format: full_html
* rules:
* -
* xpath: '//b'
* style: Bold
* -
* xpath: '//span/i'
* style: Italic
* depth: 1
* -
* plugin: dom
* method: export
* @endcode
*
* This will replace <b>...</b> with whatever style is labeled "Bold" in the
* Full HTML text format, perhaps <strong class="foo">...</strong>.
* It will also replace <span><i>...</i></span> with the style labeled "Italic"
* in that text format, perhaps <em class="foo bar">...</em>.
* You may get unexpected results if there is anything between the two opening
* tags or between the two closing tags. That is, the code assumes that
* '<span><i>' is closed with '</i></span>' exactly.
*
* @MigrateProcessPlugin(
* id = "dom_apply_styles"
* )
*/
class DomApplyStyles extends DomProcessBase implements ContainerFactoryPluginInterface {
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactory
*/
protected $configFactory;
/**
* Array of styles from the WYSIWYG editor.
*
* @var array
*/
protected $styles = [];
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ConfigFactory $config_factory) {
$configuration += ['format' => 'basic_html'];
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->configFactory = $config_factory;
$this->setStyles($configuration['format']);
$this->validateRules();
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('config.factory')
);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$this->init($value, $destination_property);
foreach ($this->configuration['rules'] as $rule) {
$this->apply($rule);
}
return $this->document;
}
/**
* Retrieve the list of styles based on configuration.
*
* The styles configuration is a string: styles are separated by "\r\n", and
* each one has the format 'element(\.class)*|label'.
* Convert this to an array with 'label' => 'element.class', and save as
* $this->styles.
*
* @param string $format
* The text format from which to get configured styles.
*
* @throws InvalidPluginDefinitionException
*/
protected function setStyles($format) {
if (empty($format) || !is_string($format)) {
$message = 'The "format" option must be a non-empty string.';
throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
}
$editor_styles = $this->configFactory
->get("editor.editor.$format")
->get('settings.plugins.stylescombo.styles');
foreach (explode("\r\n", $editor_styles) as $rule) {
if (preg_match('/(.*)\|(.*)/', $rule, $matches)) {
$this->styles[$matches[2]] = $matches[1];
}
}
}
/**
* Validate the configured rules.
*
* @throws InvalidPluginDefinitionException
*/
protected function validateRules() {
if (!array_key_exists('rules', $this->configuration) || !is_array($this->configuration['rules'])) {
$message = 'The "rules" option must be an array.';
throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
}
foreach ($this->configuration['rules'] as $rule) {
if (empty($rule['xpath']) || empty($rule['style'])) {
$message = 'The "xpath" and "style" options are required for each rule.';
throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
}
if (empty($this->styles[$rule['style']])) {
$message = sprintf('The style "%s" is not defined.', $rule['style']);
throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
}
}
}
/**
* Apply a rule to the document.
*
* Search $this->document for elements matching 'xpath' and replace them with
* the HTML elements and classes in $this->styles specified by 'style'.
* If 'depth' is positive, then replace additional parent elements as well.
*
* @param string[] $rule
* An array with keys 'xpath', 'style', and (optional) 'depth'.
*/
protected function apply(array $rule) {
// An entry in $this->styles has the format element(\.class)*: for example,
// 'p' or 'a.button' or 'div.col-xs-6.col-md-4'.
// @see setStyles()
list($element, $classes) = explode('.', $this->styles[$rule['style']] . '.', 2);
$classes = trim(str_replace('.', ' ', $classes));
foreach ($this->xpath->query($rule['xpath']) as $node) {
$new_node = $this->document->createElement($element);
foreach ($node->childNodes as $child) {
$new_node->appendChild($child->cloneNode(TRUE));
}
if ($classes) {
$new_node->setAttribute('class', $classes);
}
$old_node = $node;
if (!empty($rule['depth'])) {
for ($i = 0; $i < $rule['depth']; $i++) {
$old_node = $old_node->parentNode;
}
}
$old_node->parentNode->replaceChild($new_node, $old_node);
}
}
}
<?php
namespace Drupal\Tests\migrate_plus\Unit\process;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Utility\Html;
use Drupal\Core\Config\ConfigFactory;
use Drupal\migrate\MigrateSkipRowException;
use Drupal\migrate_plus\Plugin\migrate\process\DomApplyStyles;
use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
/**
* Tests the dom_apply_styles process plugin.
*
* @group migrate
* @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\DomApplyStyles
*/
class DomApplyStylesTest extends MigrateProcessTestCase {
/**
* Example configuration for the dom_apply_styles process plugin.
*
* @var array
*/
protected $exampleConfiguration = [
'format' => 'test_format',
'rules' => [
[
'xpath' => '//b',
'style' => 'Bold',
],
[
'xpath' => '//span/i',
'style' => 'Italic',
'depth' => 1,
],
],
];
/**
* Mock a config factory object.
*
* @var Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory = NULL;
/**
* {@inheritdoc}
*/
protected function setUp() {
// Mock a config object.
$prophecy = $this->prophesize(ImmutableConfig::class);
$prophecy
->get('settings.plugins.stylescombo.styles')
->willReturn("strong.foo|Bold\r\nem.foo.bar|Italic\r\n");
$style_config = $prophecy->reveal();
// Mock the config factory.
$prophecy = $this->prophesize(ConfigFactory::class);
$prophecy
->get('editor.editor.test_format')
->willReturn($style_config);
$this->configFactory = $prophecy->reveal();
parent::setUp();
}
/**
* @covers ::__construct
*
* @dataProvider providerTestConfig
*/
public function testValidateRules(array $config_overrides, $message) {
$configuration = $config_overrides + $this->exampleConfiguration;
$value = '<p>A simple paragraph.</p>';
$this->setExpectedException(InvalidPluginDefinitionException::class, $message);
(new DomApplyStyles($configuration, 'dom_apply_styles', [], $this->configFactory))
->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
}
/**
* Dataprovider for testValidateRules().
*/
public function providerTestConfig() {
$cases = [
'format-empty' => [
['format' => ''],
'The "format" option must be a non-empty string.',
],
'format-not-string' => [
['format' => [1, 2, 3]],
'The "format" option must be a non-empty string.',
],
'rules-not-array' => [
['rules' => 'invalid'],
'The "rules" option must be an array.',
],
'xpath-null' => [
[
'rules' => [['xpath' => NULL, 'style' => 'Bold']],
],
'The "xpath" and "style" options are required for each rule.',
],
'style-invalid' => [
[
'rules' => [['xpath' => '//b', 'style' => 'invalid-style']],
],
'The style "invalid-style" is not defined.',
],
];
return $cases;
}
/**
* @covers ::transform
*/
public function testTransformInvalidInput() {
$value = 'string';
$this->setExpectedException(MigrateSkipRowException::class, 'The dom_apply_styles plugin in the destinationproperty process pipeline requires a \DOMDocument object. You can use the dom plugin to convert a string to \DOMDocument.');
(new DomApplyStyles($this->exampleConfiguration, 'dom_apply_styles', [], $this->configFactory))
->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
}
/**
* @covers ::transform
*/
public function testTransform() {
$input_string = '<div><span><b>Bold text</b></span><span><i>Italic text</i></span></div>';
$output_string = '<div><span><strong class="foo">Bold text</strong></span><em class="foo bar">Italic text</em></div>';
$value = Html::load($input_string);
$document = (new DomApplyStyles($this->exampleConfiguration, 'dom_apply_styles', [], $this->configFactory))
->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
$this->assertTrue($document instanceof \DOMDocument);
$this->assertEquals($output_string, Html::serialize($document));
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment