‪TYPO3CMS  ‪main
CKEditor5Migrator.php
Go to the documentation of this file.
1 <?php
2 
3 declare(strict_types=1);
4 
5 /*
6  * This file is part of the TYPO3 CMS project.
7  *
8  * It is free software; you can redistribute it and/or modify it under
9  * the terms of the GNU General Public License, either version 2
10  * of the License, or any later version.
11  *
12  * For the full copyright and license information, please read the
13  * LICENSE.txt file that was distributed with this source code.
14  *
15  * The TYPO3 project - inspiring people to share!
16  */
17 
19 
21 
26 {
31  private const ‪TOOLBAR_MAIN_GROUPS_MAP = [
32  'document' => ['mode', 'document', 'doctools'],
33  'clipboard' => ['clipboard', 'undo'],
34  'editing' => ['find', 'selection', 'spellchecker', 'editing'],
35  'forms' => ['forms'],
36  'basicstyles' => ['basicstyles', 'cleanup'],
37  'paragraph' => ['list', 'indent', 'blocks', 'align', 'bidi', 'paragraph'],
38  'links' => ['links'],
39  'insert' => ['insert'],
40  'styles' => ['styles'],
41  'colors' => ['colors'],
42  'tools' => ['tools'],
43  'others' => ['others'],
44  'about' => ['about'],
45  'blocks' => ['blocks'],
46  'table' => ['table'],
47  'tabletools' => [],
48  ];
49 
53  private const ‪TOOLBAR_GROUPS_MAP = [
54  'mode' => ['Source'],
55  'document' => ['Save', 'NewPage', 'Preview', 'Print'],
56  'doctools' => ['Templates'],
57  'clipboard' => ['Cut', 'Copy', 'Paste', 'PasteText', 'PasteFromWord'],
58  'undo' => ['Undo', 'Redo'],
59  'find' => ['Find', 'Replace'],
60  'selection' => ['SelectAll'],
61  'spellchecker' => ['Scayt'],
62  'forms' => ['Form', 'Checkbox', 'Radio', 'TextField', 'Textarea', 'Select', 'Button', 'ImageButton', 'HiddenField'],
63  'basicstyles' => ['Bold', 'Italic', 'Underline', 'Strike', 'Subscript', 'Superscript', 'SoftHyphen'],
64  'cleanup' => ['CopyFormatting', 'RemoveFormat'],
65  'list' => ['NumberedList', 'BulletedList'],
66  'indent' => ['Indent', 'Outdent'],
67  'blocks' => ['Blockquote', 'CreateDiv'],
68  'align' => ['JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock'],
69  'bidi' => ['BidiLtr', 'BidiRtl', 'Language'],
70  'links' => ['Link', 'Unlink', 'Anchor'],
71  'insert' => ['Image', 'Flash', 'Table', 'HorizontalRule', 'Smiley', 'SpecialChar', 'PageBreak', 'Iframe'],
72  'styles' => ['Styles', 'Format', 'Font', 'FontSize'],
73  'format' => ['Format'],
74  'table' => ['Table'],
75  'specialchar' => ['SpecialChar'],
76  'colors' => ['TextColor', 'BGColor'],
77  'tools' => ['Maximize', 'ShowBlocks'],
78  'about' => ['About'],
79  'others' => [],
80  ];
81 
82  // List of "old" button names vs the replacement(s)
83  private const ‪BUTTON_MAP = [
84  // mode
85  'Source' => 'sourceEditing',
86  // document
87  'Save' => null,
88  'NewPage' => null,
89  'Preview' => null,
90  'Print' => null,
91  // doctools
92  'Templates' => null,
93  // clipboard
94  'Cut' => null,
95  'Copy' => null,
96  'Paste' => null,
97  'PasteText' => null,
98  'PasteFromWord' => null,
99  // undo
100  'Undo' => 'undo',
101  'Redo' => 'redo',
102  // find
103  'Find' => null,
104  'Replace' => 'findAndReplace',
105  // selection
106  'SelectAll' => 'selectAll',
107  // spellchecker
108  'Scayt' => null,
109  // forms
110  'Form' => null,
111  'Checkbox' => null,
112  'Radio' => null,
113  'TextField' => null,
114  'Textarea' => null,
115  'Select' => null,
116  'Button' => null,
117  'ImageButton' => null,
118  'HiddenField' => null,
119  // basicstyles
120  'Bold' => 'bold',
121  'Italic' => 'italic',
122  'Underline' => 'underline',
123  'Strike' => 'strikethrough',
124  'Subscript' => 'subscript',
125  'Superscript' => 'superscript',
126  // cleanup
127  'CopyFormatting' => null,
128  'RemoveFormat' => 'removeFormat',
129  // list
130  'NumberedList' => 'numberedList',
131  'BulletedList' => 'bulletedList',
132  // indent
133  'Outdent' => 'outdent',
134  'Indent' => 'indent',
135  // blocks
136  'Blockquote' => 'blockQuote',
137  'CreateDiv' => null,
138  // align
139  'JustifyLeft' => 'alignment:left',
140  'JustifyCenter' => 'alignment:center',
141  'JustifyRight' => 'alignment:right',
142  'JustifyBlock' => 'alignment:justify',
143  // bidi
144  'BidiLtr' => null,
145  'BidiRtl' => null,
146  'Language' => 'textPartLanguage',
147  // links
148  'Link' => 'link',
149  'Unlink' => null,
150  'Anchor' => null,
151  // insert
152  'Image' => 'insertImage',
153  'Flash' => null,
154  'Table' => 'insertTable',
155  'HorizontalRule' => 'horizontalLine',
156  'Smiley' => null,
157  'SpecialChar' => 'specialCharacters',
158  'PageBreak' => 'pageBreak',
159  'Iframe' => null,
160  // styles
161  'Styles' => 'style',
162  'Format' => 'heading',
163  'Font' => 'fontFamily',
164  'FontSize' => 'fontSize',
165  // colors
166  'TextColor' => 'fontColor',
167  'BGColor' => 'fontBackgroundColor',
168  // tools
169  'Maximize' => null,
170  'ShowBlocks' => null,
171  // about
172  'About' => null,
173  // typo3
174  'SoftHyphen' => 'softhyphen',
175  ];
176 
180  private const ‪PLUGIN_MAP = [
181  'image' => [
182  'module' => '@ckeditor/ckeditor5-image',
183  'exports' => [ 'Image', 'ImageCaption', 'ImageStyle', 'ImageToolbar', 'ImageUpload', 'PictureEditing' ],
184  ],
185  'Image' => [
186  'module' => '@ckeditor/ckeditor5-image',
187  'exports' => [ 'Image', 'ImageCaption', 'ImageStyle', 'ImageToolbar', 'ImageUpload', 'PictureEditing' ],
188  ],
189  'alignment' => [
190  'module' => '@ckeditor/ckeditor5-alignment',
191  'exports' => [ 'Alignment' ],
192  ],
193  'Alignment' => [
194  'module' => '@ckeditor/ckeditor5-alignment',
195  'exports' => [ 'Alignment' ],
196  ],
197  'autolink' => [
198  'module' => '@ckeditor/ckeditor5-link',
199  'exports' => [ 'AutoLink' ],
200  ],
201  'AutoLink' => [
202  'module' => '@ckeditor/ckeditor5-link',
203  'exports' => [ 'AutoLink' ],
204  ],
205  'font' => [
206  'module' => '@ckeditor/ckeditor5-font',
207  'exports' => [ 'Font' ],
208  ],
209  'Font' => [
210  'module' => '@ckeditor/ckeditor5-font',
211  'exports' => [ 'Font' ],
212  ],
213  'justify' => [
214  'module' => '@ckeditor/ckeditor5-alignment',
215  'exports' => [ 'Alignment' ],
216  ],
217  'showblocks' => [
218  'module' => '@ckeditor/ckeditor5-show-blocks',
219  'exports' => [ 'ShowBlocks' ],
220  ],
221  'ShowBlocks' => [
222  'module' => '@ckeditor/ckeditor5-show-blocks',
223  'exports' => [ 'ShowBlocks' ],
224  ],
225  'softhyphen' => [
226  'module' => '@typo3/rte-ckeditor/plugin/whitespace.js',
227  'exports' => [ 'Whitespace' ],
228  ],
229  'whitespace' => [
230  'module' => '@typo3/rte-ckeditor/plugin/whitespace.js',
231  'exports' => [ 'Whitespace' ],
232  ],
233  'Whitespace' => [
234  'module' => '@typo3/rte-ckeditor/plugin/whitespace.js',
235  'exports' => [ 'Whitespace' ],
236  ],
237  'wordcount' => [
238  'module' => '@ckeditor/ckeditor5-word-count',
239  'exports' => [ 'WordCount' ],
240  ],
241  'WordCount' => [
242  'module' => '@ckeditor/ckeditor5-word-count',
243  'exports' => [ 'WordCount' ],
244  ],
245  ];
246 
250  public function ‪__construct(protected array $configuration)
251  {
252  if (isset($this->configuration['editor']['config'])) {
253  $this->‪migrateExtraPlugins();
254  $this->‪migrateRemovePlugins();
255  $this->migrateToolbar();
261  $this->‪migrateAllowedContent();
262  // configure plugins
263  $this->‪handleAlignmentPlugin();
264  $this->‪handleWhitespacePlugin();
265  $this->‪handleWordCountPlugin();
266 
267  // sort by key
268  ksort($this->configuration['editor']['config']);
269  }
270 
271  if (isset($this->configuration['buttons']['link'])) {
273  }
274  }
275 
276  public function get(): array
277  {
278  return $this->configuration;
279  }
280 
281  protected function ‪migrateExtraPlugins(): void
282  {
283  if (!isset($this->configuration['editor']['config']['extraPlugins'])) {
284  return;
285  }
286 
287  foreach ($this->configuration['editor']['config']['extraPlugins'] as $entry) {
288  $moduleToBeLoaded = self::PLUGIN_MAP[$entry] ?? null;
289  if ($moduleToBeLoaded === null) {
290  continue;
291  }
292  $this->configuration['editor']['config']['importModules'][] = $moduleToBeLoaded;
293  $this->‪removeExtraPlugin($entry);
294  }
295  }
296 
297  protected function ‪migrateRemovePlugins(): void
298  {
299  if (!isset($this->configuration['editor']['config']['removePlugins'])) {
300  return;
301  }
302 
303  foreach ($this->configuration['editor']['config']['removePlugins'] as $key => $entry) {
304  $moduleToBeRemoved = self::PLUGIN_MAP[$entry] ?? null;
305  if ($moduleToBeRemoved !== null) {
306  unset($this->configuration['editor']['config']['removePlugins'][$key]);
307  $this->configuration['editor']['config']['removeImportModules'][] = $moduleToBeRemoved;
308  }
309  }
310  if (count($this->configuration['editor']['config']['removePlugins']) === 0) {
311  unset($this->configuration['editor']['config']['removePlugins']);
312  } else {
313  $this->configuration['editor']['config']['removePlugins'] = $this->‪getUniqueArrayValues($this->configuration['editor']['config']['removePlugins']);
314  }
315  }
316 
321  protected function migrateToolbar(): void
322  {
327  $toolbar = [
328  'items' => [],
329  'removeItems' => $this->configuration['editor']['config']['toolbar']['removeItems'] ?? [],
330  'shouldNotGroupWhenFull' => $this->configuration['editor']['config']['toolbar']['shouldNotGroupWhenFull'] ?? true,
331  ];
332 
333  // Migrate CKEditor4 toolbarGroups
334  // There can only be one configuration at a time, if 'toolbarGroups' is set
335  // we prefer this definition above the toolbar definition.
336  // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-toolbarGroups
337  if (is_array($this->configuration['editor']['config']['toolbarGroups'] ?? null)) {
338  $toolbar['items'] = $this->configuration['editor']['config']['toolbarGroups'];
339  unset($this->configuration['editor']['config']['toolbar'], $this->configuration['editor']['config']['toolbarGroups']);
340  }
341 
342  // Migrate CKEditor4 toolbar templates
343  // Resolve toolbar template and override current toolbar
344  // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-toolbar
345  if (is_string($this->configuration['editor']['config']['toolbar'] ?? null)) {
346  $toolbarName = 'toolbar_' . trim($this->configuration['editor']['config']['toolbar']);
347  if (is_array($this->configuration['editor']['config'][$toolbarName] ?? null)) {
348  $toolbar['items'] = $this->configuration['editor']['config'][$toolbarName];
349  unset($this->configuration['editor']['config']['toolbar'], $this->configuration['editor']['config'][$toolbarName]);
350  }
351  }
352 
353  // Collect toolbar items
354  if (is_array($this->configuration['editor']['config']['toolbar'] ?? null)) {
355  $toolbar['items'] = $this->configuration['editor']['config']['toolbar']['items'] ?? $this->configuration['editor']['config']['toolbar'];
356  }
357 
358  $toolbar['items'] = $this->‪migrateToolbarItems($toolbar['items']);
359  $this->configuration['editor']['config']['toolbar'] = $toolbar;
360  }
361 
362  protected function ‪migrateToolbarItems(array $items): array
363  {
364  $toolbarItems = [];
365  foreach ($items as $item) {
366  if (is_string($item)) {
367  $toolbarItems[] = $this->‪migrateToolbarButton($item);
368  continue;
369  }
370  if (is_array($item)) {
371  // Expand CKEditor4 preset toolbar groups
372  if (is_string($item['name'] ?? null) && count($item) === 1 && isset(self::TOOLBAR_MAIN_GROUPS_MAP[$item['name']])) {
373  $item['groups'] = self::TOOLBAR_MAIN_GROUPS_MAP[$item['name']];
374  }
375  // Flatten CKEditor4 arrays that only have strings assigned
376  if (count($item) === count(array_filter($item, static fn(mixed $value): bool => is_string($value)))) {
377  $migratedToolbarItems = $item;
378  $migratedToolbarItems = $this->‪migrateToolbarButtons($migratedToolbarItems);
379  $migratedToolbarItems = $this->‪migrateToolbarSpacers($migratedToolbarItems);
380  array_push($toolbarItems, ...$migratedToolbarItems);
381  $toolbarItems[] = '|';
382  continue;
383  }
384  // Flatten CKEditor4 named groups
385  if (is_string($item['name'] ?? null) && is_array($item['items'] ?? null)) {
386  $migratedToolbarItems = $item['items'];
387  $migratedToolbarItems = $this->‪migrateToolbarButtons($migratedToolbarItems);
388  $migratedToolbarItems = $this->‪migrateToolbarSpacers($migratedToolbarItems);
389  array_push($toolbarItems, ...$migratedToolbarItems);
390  $toolbarItems[] = '|';
391  continue;
392  }
393  // Expand CKEditor4 toolbar groups
394  if (is_string($item['name'] ?? null) && is_array($item['groups'] ?? null)) {
395  $itemGroups = array_filter($item['groups'], static fn(mixed $itemGroup): bool => is_string($itemGroup));
396 
397  // Process Main CKEditor4 Groups
398  $unGroupedToolbarItems = [];
399  foreach ($itemGroups as $itemGroup) {
400  if (isset(self::TOOLBAR_MAIN_GROUPS_MAP[$itemGroup])) {
401  array_push($unGroupedToolbarItems, ...self::TOOLBAR_MAIN_GROUPS_MAP[$itemGroup]);
402  $unGroupedToolbarItems[] = '|';
403  continue;
404  }
405  $unGroupedToolbarItems[] = $itemGroup;
406  }
407 
408  // Process CKEditor4 Groups
409  $groupedToolbarItems = [];
410  foreach ($itemGroups as $itemGroup) {
411  if (isset(self::TOOLBAR_GROUPS_MAP[$itemGroup])) {
412  array_push($groupedToolbarItems, ...self::TOOLBAR_GROUPS_MAP[$itemGroup]);
413  $groupedToolbarItems[] = '|';
414  continue;
415  }
416  $groupedToolbarItems[] = $itemGroup;
417  }
418 
419  $migratedToolbarItems = $groupedToolbarItems;
420  $migratedToolbarItems = $this->‪migrateToolbarButtons($migratedToolbarItems);
421  $migratedToolbarItems = $this->‪migrateToolbarSpacers($migratedToolbarItems);
422  array_push($toolbarItems, ...$migratedToolbarItems);
423  $toolbarItems[] = '|';
424  continue;
425  }
426 
427  $toolbarItems[] = $item;
428  }
429  }
430 
431  $toolbarItems = $this->‪migrateToolbarLinebreaks($toolbarItems);
432  $toolbarItems = $this->‪migrateToolbarCleanup($toolbarItems);
433 
434  return array_values($toolbarItems);
435  }
436 
437  protected function ‪migrateToolbarButton(string $buttonName): ?string
438  {
439  if (array_key_exists($buttonName, self::BUTTON_MAP)) {
440  return self::BUTTON_MAP[$buttonName];
441  }
442  return $buttonName;
443  }
444 
445  protected function ‪migrateToolbarButtons(array $toolbarItems): array
446  {
447  $processedItems = [];
448  foreach ($toolbarItems as $toolbarItem) {
449  if (is_string($toolbarItem)) {
450  if (($toolbarItem = $this->‪migrateToolbarButton($toolbarItem)) !== null) {
451  $processedItems[] = $this->‪migrateToolbarButton($toolbarItem);
452  }
453  } else {
454  $processedItems[] = $toolbarItem;
455  }
456  }
457 
458  return $processedItems;
459  }
460 
461  protected function ‪migrateToolbarSpacers(array $toolbarItems): array
462  {
463  $processedItems = [];
464  foreach ($toolbarItems as $toolbarItem) {
465  if (is_string($toolbarItem)) {
466  $toolbarItem = str_replace('-', '|', $toolbarItem);
467  }
468  $processedItems[] = $toolbarItem;
469  }
470 
471  return $processedItems;
472  }
473 
474  protected function ‪migrateToolbarLinebreaks(array $toolbarItems): array
475  {
476  $processedItems = [];
477  foreach ($toolbarItems as $toolbarItem) {
478  if (is_string($toolbarItem)) {
479  $toolbarItem = str_replace('/', '-', $toolbarItem);
480  }
481  $processedItems[] = $toolbarItem;
482  }
483 
484  return $processedItems;
485  }
486 
487  protected function ‪migrateToolbarCleanup(array $toolbarItems): array
488  {
489  // Ensure buttons are only added once to the toolbar.
490  $searchValues = [];
491  foreach ($toolbarItems as $toolbarKey => $toolbarItem) {
492  if (is_string($toolbarItem) && !in_array($toolbarItem, ['|', '-'])) {
493  if (array_key_exists($toolbarItem, $searchValues)) {
494  unset($toolbarItems[$toolbarKey]);
495  } else {
496  $searchValues[$toolbarItem] = true;
497  }
498  }
499  }
500 
501  $previousItem = null;
502  $previousKey = null;
503  foreach ($toolbarItems as $toolbarKey => $toolbarItem) {
504  if ($previousItem === null && ($toolbarItem === '|' || $toolbarItem === '-')) {
505  unset($toolbarItems[$toolbarKey]);
506  continue;
507  }
508 
509  if ($previousItem === '|' && ($toolbarItem === '|' || $toolbarItem === '-')) {
510  unset($toolbarItems[$previousKey]);
511  }
512 
513  $previousKey = $toolbarKey;
514  $previousItem = $toolbarItem;
515  }
516 
517  $lastToolbarItem = array_slice($toolbarItems, -1, 1);
518  if ($lastToolbarItem === ['-'] || $lastToolbarItem === ['|']) {
519  array_pop($toolbarItems);
520  }
521 
522  return array_values($toolbarItems);
523  }
524 
525  protected function ‪migrateRemoveButtonsFromToolbar(): void
526  {
527  if (!isset($this->configuration['editor']['config']['removeButtons'])) {
528  return;
529  }
530 
531  if (is_string($this->configuration['editor']['config']['removeButtons'])) {
532  $this->configuration['editor']['config']['removeButtons'] = ‪GeneralUtility::trimExplode(
533  ',',
534  $this->configuration['editor']['config']['removeButtons'],
535  true
536  );
537  }
538 
539  $removeItems = [];
540  foreach ($this->configuration['editor']['config']['removeButtons'] as $buttonName) {
541  if (array_key_exists($buttonName, self::BUTTON_MAP)) {
542  if (self::BUTTON_MAP[$buttonName] !== null) {
543  $removeItems[] = self::BUTTON_MAP[$buttonName];
544  }
545  } else {
546  $removeItems[] = $buttonName;
547  }
548  }
549 
550  foreach ($removeItems as $name) {
551  $this->‪removeToolbarItem($name);
552  }
553 
554  // Cleanup final configuration after migration
555  unset($this->configuration['editor']['config']['removeButtons']);
556  }
557 
558  protected function ‪migrateFormatTagsToHeadings(): void
559  {
560  // new definition is in place, no migration is done
561  if (isset($this->configuration['editor']['config']['heading']['options'])) {
562  // discard legacy configuration if new configuration exists
563  unset($this->configuration['editor']['config']['format_tags']);
564  return;
565  }
566  // migrate format_tags to custom buttons
567  if (isset($this->configuration['editor']['config']['format_tags'])) {
568  $formatTags = explode(';', $this->configuration['editor']['config']['format_tags']);
569  $allowedHeadings = [];
570  foreach ($formatTags as $paragraphTag) {
571  switch (strtolower($paragraphTag)) {
572  case 'p':
573  $allowedHeadings[] = [
574  'model' => 'paragraph',
575  'title' => 'Paragraph',
576  ];
577  break;
578  case 'h1':
579  case 'h2':
580  case 'h3':
581  case 'h4':
582  case 'h5':
583  case 'h6':
584  $headingNumber = substr($paragraphTag, -1);
585  $allowedHeadings[] = [
586  'model' => 'heading' . $headingNumber,
587  'view' => 'h' . $headingNumber,
588  'title' => 'Heading ' . $headingNumber,
589  ];
590  break;
591  case 'pre':
592  $allowedHeadings[] = [
593  'model' => 'formatted',
594  'view' => 'pre',
595  'title' => 'Formatted',
596  ];
597  }
598  }
599 
600  // remove legacy configuration after migration
601  unset($this->configuration['editor']['config']['format_tags']);
602  $this->configuration['editor']['config']['heading']['options'] = $allowedHeadings;
603  }
604  }
605 
606  protected function ‪migrateStylesSetToStyleDefinitions(): void
607  {
608  // new definition is in place, no migration is done
609  if (isset($this->configuration['editor']['config']['style']['definitions'])) {
610  // discard legacy configuration if new configuration exists
611  unset($this->configuration['editor']['config']['stylesSet']);
612  return;
613  }
614  // Migrate 'stylesSet' to 'styles' => 'definitions'
615  if (isset($this->configuration['editor']['config']['stylesSet'])) {
616  $styleDefinitions = [];
617  foreach ($this->configuration['editor']['config']['stylesSet'] as $styleSet) {
618  if (!isset($styleSet['name'], $styleSet['element'])) {
619  // @todo: log
620  continue;
621  }
622  $class = $styleSet['attributes']['class'] ?? null;
623  $definition = [
624  'name' => $styleSet['name'],
625  'element' => $styleSet['element'],
626  'classes' => [''],
627  ];
628  if ($class) {
629  $definition['classes'] = explode(' ', $class);
630  }
631  $styleDefinitions[] = $definition;
632  }
633 
634  // remove legacy configuration after migration
635  unset($this->configuration['editor']['config']['stylesSet']);
636  $this->configuration['editor']['config']['style']['definitions'] = $styleDefinitions;
637  }
638  }
639 
640  protected function ‪migrateContentsCssToArray(): void
641  {
642  if (isset($this->configuration['editor']['config']['contentsCss'])) {
643  if (!is_array($this->configuration['editor']['config']['contentsCss'])) {
644  if (empty($this->configuration['editor']['config']['contentsCss'])) {
645  unset($this->configuration['editor']['config']['contentsCss']);
646  return;
647  }
648  $this->configuration['editor']['config']['contentsCss'] = (array)$this->configuration['editor']['config']['contentsCss'];
649  }
650 
651  $this->configuration['editor']['config']['contentsCss'] = array_map(static function (mixed $styleSrc): mixed {
652  // Trim values, if input is a string, otherwise leave as-is (will be filtered out)
653  return is_string($styleSrc) ? trim($styleSrc) : $styleSrc;
654  }, $this->configuration['editor']['config']['contentsCss']);
655  $this->configuration['editor']['config']['contentsCss'] = array_values(
656  array_filter($this->configuration['editor']['config']['contentsCss'], static function (mixed $styleSrc): bool {
657  // We care for non-empty strings only
658  return is_string($styleSrc) && $styleSrc !== '';
659  })
660  );
661  }
662  }
663 
664  protected function ‪migrateTypo3LinkAdditionalAttributes(): void
665  {
666  if (!isset($this->configuration['editor']['config']['typo3link']['additionalAttributes'])) {
667  return;
668  }
669  $additionalAttributes = $this->configuration['editor']['config']['typo3link']['additionalAttributes'];
670  unset($this->configuration['editor']['config']['typo3link']['additionalAttributes']);
671  if ($this->configuration['editor']['config']['typo3link'] === []) {
672  unset($this->configuration['editor']['config']['typo3link']);
673  }
674  if (!is_array($additionalAttributes) || $additionalAttributes === []) {
675  return;
676  }
677  $this->configuration['editor']['config']['htmlSupport']['allow'][] = [
678  'name' => 'a',
679  'attributes' => array_values($additionalAttributes),
680  ];
681  }
682 
683  protected function ‪parseRuleProperties(string $properties, string $type): ?string
684  {
685  $groupsPatterns = [
686  'styles' => '/{([^}]+)}/',
687  'attrs' => '/\[([^\]]+)\]/',
688  'classes' => '/\‍(([^\‍)]+)\‍)/',
689  ];
690  $pattern = $groupsPatterns[$type] ?? null;
691  if ($pattern === null) {
692  throw new \InvalidArgumentException('Expected type to be styles, attrs or classes', 1696326899);
693  }
694 
695  $matches = [];
696  if (preg_match($pattern, $properties, $matches) === 1) {
697  return trim($matches[1]);
698  }
699 
700  return null;
701  }
702 
706  protected function ‪parseRulesString(string $input): array
707  {
708  $ruleConfig = [];
709  do {
710  $matches = [];
711  $res = preg_match(
712  // Based on https://github.com/ckeditor/ckeditor4/blob/4.23.0-lts/core/filter.js#L1431
713  // < elements >< styles, attributes and classes >< separator >
714  '/^([a-z0-9\-*\s]+)((?:\s*\{[!\w\-,\s\*]+\}\s*|\s*\[[!\w\-,\s\*]+\]\s*|\s*\‍([!\w\-,\s\*]+\‍)\s*){0,3})(?:;\s*|$)/i',
715  $input,
716  $matches
717  );
718  if ($res === false || $res === 0) {
719  return $ruleConfig;
720  }
721  $name = $matches[1];
722  $properties = $matches[2] ?? null;
723  $config = true;
724  if ($properties !== null) {
725  $config = [];
726  $config['styles'] = $this->‪parseRuleProperties($properties, 'styles');
727  $config['attributes'] = $this->‪parseRuleProperties($properties, 'attrs');
728  $config['classes'] = $this->‪parseRuleProperties($properties, 'classes');
729  }
730  $ruleConfig[$name] = $config;
731 
732  $input = substr($input, strlen($matches[0]));
733  } while ($input !== '');
734  return $ruleConfig;
735  }
736 
737  protected function ‪migrateAllowedContent(): void
738  {
739  $types = [
740  'allowedContent' => 'allow',
741  'extraAllowedContent' => 'allow',
742  'disallowedContent' => 'disallow',
743  ];
744 
745  foreach ($types as $option4 => $option5) {
746  if (!isset($this->configuration['editor']['config'][$option4])) {
747  continue;
748  }
749 
750  if ($option4 === 'allowedContent') {
751  if ($this->configuration['editor']['config']['allowedContent'] === true || $this->configuration['editor']['config']['allowedContent'] === '1') {
752  $this->configuration['editor']['config']['htmlSupport']['allow'][] = [
753  // Allow *any* tag (even custom elements)
754  'name' => [
755  'pattern' => '.+',
756  ],
757  'attributes' => true,
758  'classes' => true,
759  'styles' => true,
760  ];
761  unset($this->configuration['editor']['config']['allowedContent']);
762  continue;
763  }
764  }
765 
766  $config4 = $this->configuration['editor']['config'][$option4];
767  if (is_string($config4)) {
768  $config4 = $this->‪parseRulesString($config4);
769  }
770 
771  foreach ($config4 as $name => $options) {
772  $config = [];
773  if ($name === '*') {
774  $config['name'] = [ 'pattern' => '^[a-z]+$' ];
775  } else {
776  $name = (string)$name;
777  $config['name'] = str_contains($name, '*') || str_contains($name, ' ') ?
778  [ 'pattern' => str_replace(['*', ' '], ['.+', '|'], $name) ] :
779  $name;
780  }
781 
782  if (is_bool($options)) {
783  if ($options) {
784  $this->configuration['editor']['config']['htmlSupport'][$option5][] = $config;
785  }
786  continue;
787  }
788 
789  if (!is_array($options)) {
790  continue;
791  }
792 
793  $wildcardToRegex = fn(string $v): string|array => str_contains($v, '*') ? [ 'pattern' => str_replace('*', '.+', $v) ] : $v;
794  if (isset($options['classes'])) {
795  if ($options['classes'] === '*') {
796  $config['classes'] = true;
797  } else {
798  $config['classes'] = array_map($wildcardToRegex, explode(',', $options['classes']));
799  }
800  }
801 
802  if (isset($options['attributes'])) {
803  if ($options['attributes'] === '*') {
804  $config['attributes'] = true;
805  } else {
806  $config['attributes'] = array_map($wildcardToRegex, explode(',', $options['attributes']));
807  }
808  }
809 
810  if (isset($options['styles'])) {
811  if ($options['styles'] === '*') {
812  $config['styles'] = true;
813  } else {
814  $config['styles'] = array_map($wildcardToRegex, explode(',', $options['styles']));
815  }
816  }
817  $this->configuration['editor']['config']['htmlSupport'][$option5][] = $config;
818  }
819  unset($this->configuration['editor']['config'][$option4]);
820  }
821  }
822 
823  protected function ‪handleAlignmentPlugin(): void
824  {
825  // Migrate legacy configuration
826  // https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html#cfg-justifyClasses
827  if (isset($this->configuration['editor']['config']['justifyClasses'])) {
828  if (!isset($this->configuration['editor']['config']['alignment'])) {
829  $legacyConfig = $this->configuration['editor']['config']['justifyClasses'];
830  $indexMap = [
831  0 => 'left',
832  1 => 'center',
833  2 => 'right',
834  3 => 'justify',
835  ];
836  foreach ($legacyConfig as $index => $class) {
837  $itemConfig = [];
838  if (isset($indexMap[$index])) {
839  $itemConfig['name'] = $indexMap[$index];
840  }
841  $itemConfig['className'] = $class;
842  $this->configuration['editor']['config']['alignment']['options'][] = $itemConfig;
843  }
844  }
845  unset($this->configuration['editor']['config']['justifyClasses']);
846  }
847  $this->‪removeExtraPlugin('justify');
848 
849  // Remove related configuration if plugin should not be loaded
850  if (in_array(
851  '@ckeditor/ckeditor5-alignment',
852  array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'),
853  true
854  )) {
855  // Remove toolbar items
856  $this->‪removeToolbarItem('alignment');
857  $this->‪removeToolbarItem('alignment:left');
858  $this->‪removeToolbarItem('alignment:right');
859  $this->‪removeToolbarItem('alignment:center');
860  $this->‪removeToolbarItem('alignment:justify');
861 
862  // Remove config
863  if (isset($this->configuration['editor']['config']['alignment'])) {
864  unset($this->configuration['editor']['config']['alignment']);
865  }
866 
867  return;
868  }
869 
870  if (is_array($this->configuration['editor']['config']['alignment']['options'] ?? null)) {
871  $classMap = [];
872  foreach ($this->configuration['editor']['config']['alignment']['options'] as $option) {
873  if (is_string($option['name'] ?? null)
874  && is_string($option['className'] ?? null)
875  && in_array($option['name'], ['left', 'center', 'right', 'justify'])) {
876  $classMap[$option['name']] = $option['className'];
877  }
878  }
879  }
880 
881  // Default config
882  $this->configuration['editor']['config']['alignment'] = [
883  'options' => [
884  ['name' => 'left', 'className' => $classMap['left'] ?? 'text-start'],
885  ['name' => 'center', 'className' => $classMap['center'] ?? 'text-center'],
886  ['name' => 'right', 'className' => $classMap['right'] ?? 'text-end'],
887  ['name' => 'justify', 'className' => $classMap['justify'] ?? 'text-justify'],
888  ],
889  ];
890  }
891 
892  protected function ‪handleWhitespacePlugin(): void
893  {
894  // Remove related configuration if plugin should not be loaded
895  if (in_array(
896  '@typo3/rte-ckeditor/plugin/whitespace.js',
897  array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'),
898  true
899  )) {
900  // Remove toolbar items
901  $this->‪removeToolbarItem('softhyphen');
902 
903  return;
904  }
905 
906  // Add button if missing
907  if (!in_array('softhyphen', $this->configuration['editor']['config']['toolbar']['items'], true)) {
908  $this->configuration['editor']['config']['toolbar']['items'][] = 'softhyphen';
909  }
910  }
911 
912  protected function ‪handleWordCountPlugin(): void
913  {
914  // Migrate legacy configuration
915  //
916  // CKEditor4 used `wordcount` (lowercase), which is `wordCount` in CKEditor5.
917  // The amount of properties has been reduced.
918  //
919  // see https://ckeditor.com/docs/ckeditor5/latest/features/word-count.html
920  if (isset($this->configuration['editor']['config']['wordcount'])) {
921  if (!isset($this->configuration['editor']['config']['wordCount'])) {
922  $legacyConfig = $this->configuration['editor']['config']['wordcount'];
923  if (isset($legacyConfig['showCharCount'])) {
924  $this->configuration['editor']['config']['wordCount']['displayCharacters'] = !empty($legacyConfig['showCharCount']);
925  }
926  if (isset($legacyConfig['showWordCount'])) {
927  $this->configuration['editor']['config']['wordCount']['displayWords'] = !empty($legacyConfig['showWordCount']);
928  }
929  }
930  unset($this->configuration['editor']['config']['wordcount']);
931  }
932 
933  // Remove related configuration if plugin should not be loaded
934  if (in_array(
935  '@ckeditor/ckeditor5-word-count',
936  array_column($this->configuration['editor']['config']['removeImportModules'] ?? [], 'module'),
937  true
938  )) {
939  // Remove config
940  if (isset($this->configuration['editor']['config']['wordCount'])) {
941  unset($this->configuration['editor']['config']['wordCount']);
942  }
943 
944  return;
945  }
946 
947  // Default config
948  $this->configuration['editor']['config']['wordCount'] = [
949  'displayCharacters' => $this->configuration['editor']['config']['wordCount']['displayCharacters'] ?? true,
950  'displayWords' => $this->configuration['editor']['config']['wordCount']['displayWords'] ?? true,
951  ];
952  }
953 
954  protected function ‪addLinkClassesToStyleSets(): void
955  {
956  if (!isset($this->configuration['buttons']['link']['properties']['class']['allowedClasses'])) {
957  return;
958  }
959 
960  // Ensure editor.config.style.definitions exists
961  $this->configuration['editor']['config']['style']['definitions'] ??= [];
962 
963  $allowedClassSets = is_array($this->configuration['buttons']['link']['properties']['class']['allowedClasses'])
964  ? $this->configuration['buttons']['link']['properties']['class']['allowedClasses']
965  : ‪GeneralUtility::trimExplode(',', $this->configuration['buttons']['link']['properties']['class']['allowedClasses'], true);
966 
967  // Determine index where link classes should be added at to keep styles grouped
968  $indexToInsertElementsAt = array_key_last($this->configuration['editor']['config']['style']['definitions']) + 1;
969  foreach ($this->configuration['editor']['config']['style']['definitions'] as $index => $styleSetDefinition) {
970  if ($styleSetDefinition['element'] === 'a') {
971  $indexToInsertElementsAt = $index + 1;
972  }
973  }
974 
975  foreach ($allowedClassSets as $classSet) {
976  $allowedClasses = ‪GeneralUtility::trimExplode(' ', $classSet);
977  foreach ($this->configuration['editor']['config']['style']['definitions'] as $styleSetDefinition) {
978  if ($styleSetDefinition['element'] === 'a' && $styleSetDefinition['classes'] === $allowedClasses) {
979  // allowedClasses is already configured, continue with next one
980  continue 2;
981  }
982  }
983 
984  // We're still here, this means $allowedClasses wasn't found
985  array_splice($this->configuration['editor']['config']['style']['definitions'], $indexToInsertElementsAt, 0, [[
986  'classes' => $allowedClasses,
987  'element' => 'a',
988  'name' => implode(' ', $allowedClasses), // we lack a human-readable name here...
989  ]]);
990  $indexToInsertElementsAt++;
991  }
992  }
993 
994  private function ‪removeToolbarItem(string $name): void
995  {
996  $this->configuration['editor']['config']['toolbar']['removeItems'][] = $name;
997  $this->configuration['editor']['config']['toolbar']['removeItems'] = $this->‪getUniqueArrayValues($this->configuration['editor']['config']['toolbar']['removeItems']);
998  }
999 
1000  private function ‪removeExtraPlugin(string $name): void
1001  {
1002  if (!isset($this->configuration['editor']['config']['extraPlugins'])) {
1003  return;
1004  }
1005 
1006  $this->configuration['editor']['config']['extraPlugins'] = array_filter($this->configuration['editor']['config']['extraPlugins'], static function (string $value) use ($name) {
1007  return $value !== $name;
1008  });
1009 
1010  if (empty($this->configuration['editor']['config']['extraPlugins'])) {
1011  unset($this->configuration['editor']['config']['extraPlugins']);
1012  return;
1013  }
1014 
1015  $this->configuration['editor']['config']['extraPlugins'] = $this->‪getUniqueArrayValues($this->configuration['editor']['config']['extraPlugins']);
1016  }
1017 
1022  private function ‪getUniqueArrayValues(array $array)
1023  {
1024  return array_values(array_unique($array));
1025  }
1026 }
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarSpacers
‪migrateToolbarSpacers(array $toolbarItems)
Definition: CKEditor5Migrator.php:461
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\handleWordCountPlugin
‪handleWordCountPlugin()
Definition: CKEditor5Migrator.php:912
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarLinebreaks
‪migrateToolbarLinebreaks(array $toolbarItems)
Definition: CKEditor5Migrator.php:474
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateAllowedContent
‪migrateAllowedContent()
Definition: CKEditor5Migrator.php:737
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarButtons
‪migrateToolbarButtons(array $toolbarItems)
Definition: CKEditor5Migrator.php:445
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\removeToolbarItem
‪removeToolbarItem(string $name)
Definition: CKEditor5Migrator.php:994
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateExtraPlugins
‪migrateExtraPlugins()
Definition: CKEditor5Migrator.php:281
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateRemovePlugins
‪migrateRemovePlugins()
Definition: CKEditor5Migrator.php:297
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\parseRuleProperties
‪parseRuleProperties(string $properties, string $type)
Definition: CKEditor5Migrator.php:683
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarItems
‪migrateToolbarItems(array $items)
Definition: CKEditor5Migrator.php:362
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\__construct
‪__construct(protected array $configuration)
Definition: CKEditor5Migrator.php:250
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateRemoveButtonsFromToolbar
‪migrateRemoveButtonsFromToolbar()
Definition: CKEditor5Migrator.php:525
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\addLinkClassesToStyleSets
‪addLinkClassesToStyleSets()
Definition: CKEditor5Migrator.php:954
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\TOOLBAR_MAIN_GROUPS_MAP
‪const TOOLBAR_MAIN_GROUPS_MAP
Definition: CKEditor5Migrator.php:31
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarButton
‪migrateToolbarButton(string $buttonName)
Definition: CKEditor5Migrator.php:437
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateStylesSetToStyleDefinitions
‪migrateStylesSetToStyleDefinitions()
Definition: CKEditor5Migrator.php:606
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\removeExtraPlugin
‪removeExtraPlugin(string $name)
Definition: CKEditor5Migrator.php:1000
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateTypo3LinkAdditionalAttributes
‪migrateTypo3LinkAdditionalAttributes()
Definition: CKEditor5Migrator.php:664
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\getUniqueArrayValues
‪getUniqueArrayValues(array $array)
Definition: CKEditor5Migrator.php:1022
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\handleAlignmentPlugin
‪handleAlignmentPlugin()
Definition: CKEditor5Migrator.php:823
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateContentsCssToArray
‪migrateContentsCssToArray()
Definition: CKEditor5Migrator.php:640
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\PLUGIN_MAP
‪const PLUGIN_MAP
Definition: CKEditor5Migrator.php:180
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\TOOLBAR_GROUPS_MAP
‪const TOOLBAR_GROUPS_MAP
Definition: CKEditor5Migrator.php:53
‪TYPO3\CMS\Core\Configuration
Definition: CKEditor5Migrator.php:18
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator
Definition: CKEditor5Migrator.php:26
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\BUTTON_MAP
‪const BUTTON_MAP
Definition: CKEditor5Migrator.php:83
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:52
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\parseRulesString
‪parseRulesString(string $input)
Definition: CKEditor5Migrator.php:706
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateFormatTagsToHeadings
‪migrateFormatTagsToHeadings()
Definition: CKEditor5Migrator.php:558
‪TYPO3\CMS\Core\Utility\GeneralUtility\trimExplode
‪static list< string > trimExplode(string $delim, string $string, bool $removeEmptyValues=false, int $limit=0)
Definition: GeneralUtility.php:822
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\migrateToolbarCleanup
‪migrateToolbarCleanup(array $toolbarItems)
Definition: CKEditor5Migrator.php:487
‪TYPO3\CMS\Core\Configuration\CKEditor5Migrator\handleWhitespacePlugin
‪handleWhitespacePlugin()
Definition: CKEditor5Migrator.php:892