checkpoints.yaml
# Checkpoints for typo3-ckeditor5 skill
# Validates CKEditor 5 plugin structure and TYPO3 integration
#
# CONSTRAINTS EVERY `type: command` PATTERN BELOW MUST OBEY — read before editing.
# They are not style; each one produced a checkpoint that could not pass at all,
# or that fired against a project this skill does not apply to.
#
# 1. Prune the dependency trees. A bare `find .` reaches .Build/vendor/, vendor/
# and node_modules/ — all gitignored, none of them this project's code. An
# unpruned precondition matched .Build/vendor/typo3/cms-core/Configuration/RTE/
# SysNews.yaml in an extension with no RTE code at all, so all 14 checks ran and
# every failure was noise. Hence the `'(' -name .Build -o -name vendor -o -name
# node_modules ')' -prune -o` prefix on every find, with the explicit `-print`
# the `-o` form needs (without it the pruned directories print themselves).
# Quote the parentheses: bare `(` is shell syntax.
# 2. No `find -exec`. The runner's allowlist rejects the substring `exec ` as a
# dangerous pattern, so `-exec grep -l X {} +` never runs — the check reports
# "Command rejected" on every project, forever. Use `-print | xargs grep -l X`.
# 3. No `||`, `&&`, `;`, backticks or command substitution — the runner rejects
# them outright. That also means a trailing `|| true` is not a way to make a
# check advisory; it makes the check permanently fail. Plain pipes are fine.
# 4. Never write the two characters `$(` — the allowlist reads them as command
# substitution even inside a quoted grep pattern. Spell a literal `$(` for grep
# as the bracket expression `[$][(]`.
#
# Three spellings appear throughout because they carry the file's whole file-type
# vocabulary in one glob each: `*.y*ml` covers .yaml and .yml, `[Cc]keditor`
# covers both directory spellings the precondition accepts, and `*.[jt]s` covers
# .js and .ts so a TypeScript-authored plugin is scanned by the same content
# checks that gate a .js one — CK-07 accepted .ts while CK-08/09/12/13 read only
# .js, so a .ts plugin passed the existence check with its jQuery use unscanned.
#
# In `-path`, `*` matches `/` as well (find(1): the metacharacters do not treat
# `/` or `.` specially), so `*/JavaScript/[Cc]keditor*.[jt]s` reaches both
# `JavaScript/Ckeditor/bundle.js` and `JavaScript/CkeditorFoo.js`. No trailing
# `/*` variant is needed and adding one would drop the flat spelling.
version: 1
skill_id: typo3-ckeditor5
preconditions:
- type: file_exists
target: ext_emconf.php
# Both branches name exactly the paths the checks below inspect. A wider
# precondition activates all 14 checks against a project none of them apply
# to: `*/RTE/*.y*ml` let `Documentation/RTE/example.yaml` in, and a bare
# `*/JavaScript/[Cc]keditor*` let any file under such a directory in — a
# README, an image. Measured on both: 14 checks ran, 8 failed, and not one
# of the failures was about the project.
- type: command
pattern: 'find . \( -name .Build -o -name vendor -o -name node_modules \) -prune -o -type f \( -path "*/JavaScript/[Cc]keditor*.[jt]s" -o -path "*/Configuration/RTE/*.y*ml" \) -print | grep -q .'
mechanical:
# === PLUGIN REGISTRATION ===
- id: CK-01
type: file_exists
target: ext_localconf.php
severity: error
desc: "ext_localconf.php must exist for plugin registration"
- id: CK-02
type: contains
target: ext_localconf.php
pattern: "RTE"
severity: warning
desc: "ext_localconf.php should register RTE presets"
- id: CK-03
type: contains
target: ext_localconf.php
pattern: "ckeditor5"
severity: warning
desc: "ext_localconf.php should register CKEditor 5 plugins"
# === RTE YAML CONFIGURATION ===
- id: CK-04
type: command
pattern: "find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/Configuration/RTE/*.y*ml' -print | head -1 | grep -q ."
severity: warning
desc: "RTE YAML preset should exist in Configuration/RTE/"
- id: CK-05
type: command
pattern: "find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/Configuration/RTE/*.y*ml' -print | xargs grep -l 'editor:' | grep -q ."
severity: warning
desc: "RTE YAML preset should have editor configuration block"
- id: CK-06
type: command
pattern: "find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/Configuration/RTE/*.y*ml' -print | xargs grep -l 'toolbar:' | grep -q ."
severity: info
desc: "RTE YAML preset should configure toolbar items"
# === PLUGIN JS FILES ===
- id: CK-07
type: command
pattern: "find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/JavaScript/[Cc]keditor*.[jt]s' -print | head -1 | grep -q ."
severity: warning
desc: "CKEditor plugin JavaScript files should exist"
# === NO JQUERY IN CKEDITOR CODE ===
- id: CK-08
type: command
pattern: "! find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/JavaScript/[Cc]keditor*.[jt]s' -print | xargs grep -li 'jquery' | grep -q ."
severity: error
desc: "CKEditor 5 plugins must not import jQuery"
- id: CK-09
type: command
pattern: "! find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/JavaScript/[Cc]keditor*.[jt]s' -print | xargs grep -lE '([$]|jQuery)[(]' | grep -q ."
severity: warning
desc: "CKEditor 5 plugins should use native DOM APIs, not jQuery selectors"
# === LEGACY PATTERNS ===
- id: CK-10
type: command
pattern: "! find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/Configuration/RTE/*.y*ml' -print | xargs grep -l 'CKEditor4' | grep -q ."
severity: warning
desc: "RTE config should not reference CKEditor 4 patterns"
# === PROCESSING SECTION IN RTE YAML ===
- id: CK-11
type: command
pattern: "find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/Configuration/RTE/*.y*ml' -print | xargs grep -l 'processing:' | grep -q ."
severity: info
desc: "RTE YAML preset should have processing section (allowTags, allowAttributes)"
# === NO $.DEFERRED IN CKEditor CODE ===
- id: CK-12
type: command
pattern: "! find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/JavaScript/[Cc]keditor*.[jt]s' -print | xargs grep -l 'Deferred' | grep -q ."
severity: warning
desc: "CKEditor 5 plugins should use native Promise, not jQuery $.Deferred"
# === NO FETCH WITH JQUERY ===
- id: CK-13
type: command
pattern: |
! find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune \
-o -type f -path '*/JavaScript/[Cc]keditor*.[jt]s' -print \
| xargs grep -lE '([$]|jQuery)[.](get|ajax|post)' | grep -q .
severity: warning
desc: "CKEditor 5 plugins should use native fetch(), not jQuery AJAX methods"
# === PLUGIN HAS SEPARATE EDITING AND UI ===
- id: CK-14
type: command
pattern: "find . '(' -name .Build -o -name vendor -o -name node_modules ')' -prune -o -type f -path '*/JavaScript/[Cc]keditor*[Ee]diting*' -print | head -1 | grep -q ."
severity: info
desc: "CKEditor 5 plugins should separate Editing and UI concerns into separate files"
llm_reviews:
- id: CK-20
domain: ckeditor5
prompt: |
Review the CKEditor 5 TYPO3 integration for correctness:
1. Is the plugin registered in ext_localconf.php with correct entryPoint path?
2. Does the RTE YAML preset follow the editor.config structure?
3. Does the plugin follow class-based architecture (separate Editing + UI plugins)?
4. Are schema, converters, and commands properly separated?
5. Do AJAX response handlers use correct backend property names?
severity: warning
desc: "CKEditor 5 plugin structure and registration"
- id: CK-21
domain: ckeditor5
prompt: |
Review the CKEditor 5 plugin for modern patterns:
1. Are native DOM APIs used instead of jQuery?
2. Is the plugin compatible with TYPO3 v12+ CKEditor 5 API?
3. Are toolbar items properly named and grouped in YAML?
4. Is content processing (upcast/downcast converters) correctly implemented?
5. Does the plugin avoid deprecated CKEditor 4 patterns?
severity: info
desc: "CKEditor 5 modern pattern adherence"
references/ckeditor5-architecture.md
# CKEditor 5 Architecture Gotchas
The public CKEditor 5 docs cover MVC architecture, schema syntax, conversion,
commands, events, widgets, and the data pipeline in full -- consult the
[official CKEditor 5 framework docs](https://ckeditor.com/docs/ckeditor5/latest/framework/architecture/intro.html)
for that reference material. This file only covers behavior that diverges
from what those docs suggest, found while building TYPO3 CKE5 plugins.
## figcaption Content Model Limitations
CKEditor 5's `ImageCaption` plugin registers `caption` with `allowContentOf: '$block'`, which includes inline text and inline elements but **NOT** `softBreak` (the internal model for `<br>`).
**Consequences:**
- `<br>` tags inside `<figcaption>` are stripped on save — both Shift+Enter and source-mode `<br>` fail
- This is a CKEditor 5 core limitation, not an extension bug
- Captions only wrap naturally based on container width
**CSS scoping:** Use `figure.image figcaption` (not bare `figure figcaption`) to target only CKEditor-generated figures and avoid affecting other `<figcaption>` elements on the page.
## Pitfall: View Elements Are Not DOM Elements
Inside upcast/downcast converter callbacks, the element you receive is a
**CKEditor 5 view element**, not a DOM element. View elements expose a
`getAttribute(key)` method that *mirrors the DOM API by name only* — it
reads from an internal attribute map. Crucially, view elements have:
- `getAttribute(key)` / `hasAttribute(key)` (returns strings / booleans)
- NO `.dataset` property
- NO `.classList` (use `hasClass()` / `getClasses()`, or use the
view writer's `addClass` / `removeClass` for downcast)
Do NOT apply DOM-targeted lint rules (e.g. SonarCloud's
`javascript:S7761` "prefer `.dataset` over `getAttribute('data-*')`")
to converter callbacks. The auto-suggested transformation will silently
return `undefined` and drop every `data-*` attribute on upcast — the
failure is invisible to tests that mock the view tree.
### Identifying view-element callsites
When reviewing `getAttribute('data-*')` calls, look at sibling calls in
the same block:
| Sibling call | Context |
|---|---|
| `consumable.consume(el, { name: true })` | Upcast converter |
| `el.is('element', 'img')` | View tree pattern matching |
| `el.getChildren()` / `getChild(i)` | View tree traversal |
| `writer.setAttribute(...)` (with view writer) | Downcast converter |
| `editor.conversion.for('upcast')...` | Definitely view |
If any of these appear nearby, you're operating on a view element —
keep `getAttribute()`, do not introduce `.dataset`.
### When `.dataset` IS appropriate
Only convert when the receiver is a real DOM element. Examples in a
plugin context:
- `targetDoc.createElement('input')` then setting attributes
- `editor.editing.view.getDomRoot()` followed by DOM access
- Anything inside `editor.ui.componentFactory` callbacks that touches
`<button>`, `<input>`, etc. directly via `domConverter.viewToDom(...)`
### Real-world case
In the t3x-rte_ckeditor_image SonarCloud evaluation (2026-05, PR #813),
53 of 54 `javascript:S7761` instances on `typo3image.js` were exactly
this false-positive class. The single true-DOM call (`hiddenInput` from
`createElement('input')`) was converted; the rest were left and bulk
marked won't-fix.
references/migration-guide.md
# CKEditor 4 to 5 Migration Guide
## Overview
CKEditor 5 is a complete rewrite with different architecture. Migration requires:
1. Understanding architectural differences
2. Converting custom plugins
3. Updating configuration
4. Testing content compatibility
## Architectural Differences
### CKEditor 4 Architecture
```javascript
// CKEditor 4: Plugin structure
CKEDITOR.plugins.add('myplugin', {
requires: 'widget',
icons: 'myplugin',
init: function(editor) {
editor.widgets.add('myWidget', {
template: '<div class="my-widget">{content}</div>',
editables: {
content: '.my-widget-content'
},
upcast: function(element) {
return element.name === 'div' &&
element.hasClass('my-widget');
}
});
editor.addCommand('insertMyWidget', {
exec: function(editor) {
editor.insertHtml('<div class="my-widget">Content</div>');
}
});
editor.ui.addButton('MyWidget', {
label: 'Insert Widget',
command: 'insertMyWidget',
toolbar: 'insert'
});
}
});
```
### CKEditor 5 Architecture
```javascript
// CKEditor 5: Class-based plugin
import { Plugin } from '@ckeditor/ckeditor5-core';
import { Widget, toWidget } from '@ckeditor/ckeditor5-widget';
import { Command } from '@ckeditor/ckeditor5-core';
import { ButtonView } from '@ckeditor/ckeditor5-ui';
export default class MyPlugin extends Plugin {
static get requires() {
return [Widget];
}
init() {
this._defineSchema();
this._defineConverters();
this._defineCommands();
this._defineUI();
}
_defineSchema() {
const schema = this.editor.model.schema;
schema.register('myWidget', {
inheritAllFrom: '$blockObject'
});
}
_defineConverters() {
const conversion = this.editor.conversion;
conversion.for('upcast').elementToElement({
view: { name: 'div', classes: 'my-widget' },
model: 'myWidget'
});
conversion.for('editingDowncast').elementToElement({
model: 'myWidget',
view: (modelElement, { writer }) => {
const div = writer.createContainerElement('div', {
class: 'my-widget'
});
return toWidget(div, writer);
}
});
}
_defineCommands() {
this.editor.commands.add('insertMyWidget',
new InsertMyWidgetCommand(this.editor));
}
_defineUI() {
this.editor.ui.componentFactory.add('myWidget', locale => {
const button = new ButtonView(locale);
button.set({ label: 'Insert Widget', tooltip: true });
button.on('execute', () => {
this.editor.execute('insertMyWidget');
});
return button;
});
}
}
```
## Key Differences
| Aspect | CKEditor 4 | CKEditor 5 |
|--------|-----------|------------|
| Plugin System | Object-based registration | ES6 class-based |
| Data Model | DOM-based | Abstract MVC model |
| Commands | Simple exec functions | Command class pattern |
| UI | jQuery-based | Observable View classes |
| Conversion | Upcast/downcast in one | Separate upcast/downcast |
| Widgets | Widget plugin | Built-in widget system |
| Configuration | JavaScript object | YAML (TYPO3) + JS |
## Migration Checklist
### Pre-Migration Assessment
- [ ] Audit all CKEditor 4 plugins in use
- [ ] List custom plugins requiring conversion
- [ ] Identify configuration customizations
- [ ] Document existing content formats
- [ ] Test content rendering requirements
- [ ] Plan testing strategy
### Plugin Migration Steps
#### 1. Convert Plugin Structure
```javascript
// CKEditor 4
CKEDITOR.plugins.add('infobox', {
init: function(editor) {
// All logic here
}
});
// CKEditor 5
export default class InfoBox extends Plugin {
static get requires() { return [InfoBoxEditing, InfoBoxUI]; }
static get pluginName() { return 'InfoBox'; }
}
export class InfoBoxEditing extends Plugin {
init() {
// Schema, converters, commands
}
}
export class InfoBoxUI extends Plugin {
init() {
// UI components
}
}
```
#### 2. Convert Schema/Data Model
```javascript
// CKEditor 4: Allowed content rules
CKEDITOR.plugins.add('infobox', {
init: function(editor) {
editor.filter.allow('div[class,data-type]{*}');
}
});
// CKEditor 5: Schema registration
_defineSchema() {
const schema = this.editor.model.schema;
schema.register('infoBox', {
inheritAllFrom: '$blockObject',
allowAttributes: ['infoType']
});
}
```
#### 3. Convert Data Conversion
```javascript
// CKEditor 4: Widget upcast
CKEDITOR.plugins.add('infobox', {
init: function(editor) {
editor.widgets.add('infobox', {
upcast: function(element) {
return element.name === 'div' &&
element.hasClass('info-box');
},
data: function() {
this.element.setAttribute('data-type', this.data.type);
}
});
}
});
// CKEditor 5: Conversion
_defineConverters() {
const conversion = this.editor.conversion;
// Upcast (view -> model)
conversion.for('upcast').elementToElement({
view: {
name: 'div',
classes: 'info-box'
},
model: (viewElement, { writer }) => {
return writer.createElement('infoBox', {
infoType: viewElement.getAttribute('data-type')
});
}
});
// Downcast (model -> view)
conversion.for('downcast').elementToElement({
model: 'infoBox',
view: (modelElement, { writer }) => {
return writer.createContainerElement('div', {
class: 'info-box',
'data-type': modelElement.getAttribute('infoType')
});
}
});
}
```
#### 4. Convert Commands
```javascript
// CKEditor 4: Command
CKEDITOR.plugins.add('infobox', {
init: function(editor) {
editor.addCommand('insertInfoBox', {
exec: function(editor) {
var element = new CKEDITOR.dom.element('div');
element.addClass('info-box');
editor.insertElement(element);
}
});
}
});
// CKEditor 5: Command class
export class InsertInfoBoxCommand extends Command {
execute(options = {}) {
const model = this.editor.model;
model.change(writer => {
const infoBox = writer.createElement('infoBox', {
infoType: options.type || 'default'
});
model.insertObject(infoBox, null, null, {
setSelection: 'on'
});
});
}
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const allowedIn = model.schema.findAllowedParent(
selection.getFirstPosition(),
'infoBox'
);
this.isEnabled = allowedIn !== null;
}
}
```
#### 5. Convert UI Components
```javascript
// CKEditor 4: Button
CKEDITOR.plugins.add('infobox', {
init: function(editor) {
editor.ui.addButton('InfoBox', {
label: 'Insert Info Box',
command: 'insertInfoBox',
toolbar: 'insert',
icon: this.path + 'icons/infobox.png'
});
}
});
// CKEditor 5: ButtonView
_defineUI() {
const editor = this.editor;
editor.ui.componentFactory.add('infoBox', locale => {
const command = editor.commands.get('insertInfoBox');
const button = new ButtonView(locale);
button.set({
label: editor.t('Insert Info Box'),
icon: infoBoxIcon,
tooltip: true
});
button.bind('isEnabled').to(command);
button.on('execute', () => {
editor.execute('insertInfoBox');
editor.editing.view.focus();
});
return button;
});
}
```
## Configuration Migration
### CKEditor 4 Configuration (PageTSConfig)
```typoscript
# TYPO3 CKEditor 4 configuration
RTE.default {
showStatusBar = 0
buttons {
bold.hotKey = ctrl+b
italic.hotKey = ctrl+i
}
proc {
allowedClasses = info-box, warning-box
allowTags = p, br, strong, em, ul, ol, li, a, div
}
contentCSS = EXT:my_extension/Resources/Public/Css/rte.css
}
```
### CKEditor 5 Configuration (YAML)
```yaml
# Configuration/RTE/Default.yaml
editor:
config:
toolbar:
items:
- heading
- '|'
- bold
- italic
- '|'
- bulletedList
- numberedList
- '|'
- link
- infoBox
# Keyboard shortcuts
keystrokes:
- [ctrl, 66, 'bold'] # Ctrl+B
- [ctrl, 73, 'italic'] # Ctrl+I
importModules:
- '@vendor/my_extension/ckeditor/info-box.js'
processing:
allowTags:
- p
- br
- strong
- em
- ul
- ol
- li
- a
- div
allowAttributes:
- { attribute: 'class', elements: 'div' }
- { attribute: 'data-type', elements: 'div' }
```
## TYPO3-Specific Migration
### ext_localconf.php Changes
```php
<?php
// CKEditor 4 (old)
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['my_preset'] =
'EXT:my_extension/Configuration/RTE/CKEditor4.yaml';
// CKEditor 5 (new)
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['my_preset'] =
'EXT:my_extension/Configuration/RTE/Default.yaml';
// Register CKEditor 5 plugin
$GLOBALS['TYPO3_CONF_VARS']['RTE']['CKEditor5']['plugins']['info-box'] = [
'entryPoint' => 'EXT:my_extension/Resources/Public/JavaScript/Ckeditor/info-box.js',
'stylesheets' => [
'EXT:my_extension/Resources/Public/Css/Ckeditor/info-box.css',
],
];
```
### Processing Rules Migration
```yaml
# CKEditor 4 processing (TYPO3 v11)
processing:
mode: default
HTMLparser_db:
allowTags: p,br,strong,em,a,ul,ol,li
HTMLparser_rte:
allowTags: p,br,strong,em,a,ul,ol,li
# CKEditor 5 processing (TYPO3 v12+)
processing:
mode: default
allowTags:
- p
- br
- strong
- em
- a
- ul
- ol
- li
allowAttributes:
- { attribute: 'href', elements: 'a' }
- { attribute: 'target', elements: 'a' }
```
## Content Compatibility
### Existing Content Testing
```php
<?php
// Test script to validate content rendering
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
$connection = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tt_content');
$rows = $connection->select(
['uid', 'bodytext'],
'tt_content',
['CType' => 'text']
)->fetchAllAssociative();
foreach ($rows as $row) {
$content = $row['bodytext'];
// Check for CKEditor 4 specific patterns
$issues = [];
// Check for deprecated widgets
if (strpos($content, 'data-cke-widget') !== false) {
$issues[] = "CKEditor 4 widget markup in uid {$row['uid']}";
}
// Check for deprecated classes
if (strpos($content, 'cke_') !== false) {
$issues[] = "CKEditor 4 class names in uid {$row['uid']}";
}
if (!empty($issues)) {
echo implode("\n", $issues) . "\n";
}
}
```
### Content Migration Script
```php
<?php
// Migration command for content updates
namespace Vendor\MyExtension\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
final class MigrateRteContentCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tt_content');
$rows = $connection->select(
['uid', 'bodytext'],
'tt_content',
[]
)->fetchAllAssociative();
$updated = 0;
foreach ($rows as $row) {
$content = $row['bodytext'];
$originalContent = $content;
// Remove CKEditor 4 widget wrappers
$content = preg_replace(
'/<div[^>]*data-cke-widget[^>]*>(.*?)<\/div>/s',
'$1',
$content
);
// Convert deprecated markup
$content = str_replace(
['<b>', '</b>', '<i>', '</i>'],
['<strong>', '</strong>', '<em>', '</em>'],
$content
);
// Update if changed
if ($content !== $originalContent) {
$connection->update(
'tt_content',
['bodytext' => $content],
['uid' => $row['uid']]
);
$updated++;
$output->writeln("Updated uid {$row['uid']}");
}
}
$output->writeln("Updated $updated records");
return Command::SUCCESS;
}
}
```
## Common Migration Issues
### Issue 1: Widget Markup Differences
```html
<!-- CKEditor 4 widget output -->
<div class="cke_widget_wrapper" data-cke-widget-id="0">
<div class="info-box" data-widget="infobox">
Content here
</div>
</div>
<!-- CKEditor 5 output (cleaner) -->
<div class="info-box" data-type="info">
Content here
</div>
```
### Issue 2: Link Handling
```javascript
// CKEditor 4: Link dialog
editor.on('doubleclick', function(evt) {
var element = evt.data.element;
if (element.is('a')) {
evt.data.dialog = 'link';
}
});
// CKEditor 5: Built-in link handling via linkConfig
// Configuration in YAML
editor:
config:
link:
decorators:
openInNewTab:
mode: manual
label: 'Open in new tab'
attributes:
target: '_blank'
```
### Issue 3: Table Handling
```yaml
# CKEditor 5 table configuration
editor:
config:
table:
contentToolbar:
- tableColumn
- tableRow
- mergeTableCells
- tableProperties
- tableCellProperties
tableProperties:
borderColors:
- { color: 'hsl(0, 0%, 0%)', label: 'Black' }
- { color: 'hsl(0, 0%, 30%)', label: 'Dim grey' }
- { color: 'hsl(0, 0%, 60%)', label: 'Grey' }
```
## Testing Strategy
### Unit Tests for Converted Plugins
```javascript
import { expect } from 'chai';
import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic';
import { Paragraph } from '@ckeditor/ckeditor5-paragraph';
import InfoBox from '../src/infobox';
describe('InfoBox Plugin Migration', () => {
let editor;
beforeEach(async () => {
editor = await ClassicEditor.create(
document.createElement('div'),
{ plugins: [Paragraph, InfoBox] }
);
});
afterEach(async () => {
await editor.destroy();
});
it('should upcast CKEditor 4 markup', () => {
// CKEditor 4 format
editor.setData('<div class="info-box" data-type="warning">Test</div>');
const root = editor.model.document.getRoot();
const infoBox = root.getChild(0);
expect(infoBox.name).to.equal('infoBox');
expect(infoBox.getAttribute('infoType')).to.equal('warning');
});
it('should downcast to clean HTML', () => {
editor.model.change(writer => {
const infoBox = writer.createElement('infoBox', {
infoType: 'info'
});
const paragraph = writer.createElement('paragraph');
writer.insertText('Test', paragraph);
writer.append(paragraph, infoBox);
writer.insert(infoBox, editor.model.document.getRoot(), 0);
});
const output = editor.getData();
expect(output).to.include('class="info-box"');
expect(output).to.include('data-type="info"');
expect(output).not.to.include('cke_');
});
});
```
### Integration Tests
```php
<?php
// TYPO3 functional test for RTE output
namespace Vendor\MyExtension\Tests\Functional;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
final class RteOutputTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = [
'typo3conf/ext/my_extension',
];
/**
* @test
*/
public function rteContentRendersCorrectly(): void
{
$this->importCSVDataSet(__DIR__ . '/Fixtures/pages.csv');
$this->importCSVDataSet(__DIR__ . '/Fixtures/tt_content.csv');
$this->setUpFrontendRootPage(
1,
['EXT:my_extension/Configuration/TypoScript/setup.typoscript']
);
$response = $this->executeFrontendSubRequest(
new InternalRequest('https://example.com/')
);
$body = (string)$response->getBody();
// Verify CKEditor 5 output format
self::assertStringContainsString('class="info-box"', $body);
self::assertStringNotContainsString('data-cke-widget', $body);
}
}
```
## Rollback Strategy
### Feature Flag Implementation
```php
<?php
// ext_localconf.php
// Use feature flag for gradual rollout
if (\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(
\TYPO3\CMS\Core\Configuration\Features::class
)->isFeatureEnabled('myExtension.useCkeditor5')) {
// CKEditor 5 configuration
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['default'] =
'EXT:my_extension/Configuration/RTE/CKEditor5.yaml';
} else {
// CKEditor 4 fallback (TYPO3 v11)
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['default'] =
'EXT:my_extension/Configuration/RTE/CKEditor4.yaml';
}
```
```yaml
# config/system/settings.php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['myExtension.useCkeditor5'] = true;
```
## jQuery Removal Migration
TYPO3 backend JS is dropping jQuery. The `rte_ckeditor` system extension already has zero jQuery. Backend JS is **NOT** covered by the deprecation policy, so jQuery can vanish without notice. Migrate proactively.
### Step-by-Step Migration Order (Lowest Risk First)
Migrate in this order to keep each step small and testable:
1. **`$.extend`** → `Object.assign()` or spread syntax `{ ...a, ...b }`
2. **`$.each(collection, callback)`** → `for...of` / `Array.prototype.forEach`
3. **`$.getJSON` / `$.ajax`** → `fetch()` with `response.ok` check
4. **`$.Deferred()`** → `new Promise()` with extracted resolve/reject refs
5. **Iframe DOM access** → `querySelector` + `contentDocument` + `dataset`
6. **Dialog DOM builder** → `h(tag, className, parent)` helper (see plugin-development.md)
7. **Remove `import $ from 'jquery'`** — only after all usages are gone
### Critical: `$.each` to `for...of` and Variable Scoping
**CRITICAL:** When converting `$.each(callback)` to `for...of`, `var` declarations inside the callback lose per-iteration function scope. You **must** convert `var` to `let`/`const` simultaneously, or closures that capture loop variables will break.
```javascript
// jQuery with var -- WORKS because $.each creates a new function scope per iteration
$.each(items, function(i, item) {
var value = item.name;
setTimeout(function() {
console.log(value); // Correct: each iteration has its own 'value'
}, 100);
});
// BROKEN: for...of with var -- var is function-scoped, NOT block-scoped
for (const item of items) {
var value = item.name;
setTimeout(function() {
console.log(value); // BUG: always logs last item's name
}, 100);
}
// CORRECT: for...of with let -- let is block-scoped
for (const item of items) {
let value = item.name; // or: const value = item.name;
setTimeout(function() {
console.log(value); // Correct: each iteration has its own 'value'
}, 100);
}
```
### Event Migration: `mousewheel` → `wheel`
The `mousewheel` event is non-standard (WebKit/IE). Use the standard `wheel` event:
```javascript
// Old (jQuery + mousewheel)
$element.on('mousewheel', function(e) {
e.preventDefault();
zoom += e.originalEvent.wheelDelta > 0 ? step : -step;
});
// New (native + wheel) -- deltaY is INVERTED vs wheelDelta
element.addEventListener('wheel', (e) => {
e.preventDefault();
zoom += e.deltaY < 0 ? step : -step;
}, { passive: false }); // passive: false required for preventDefault()
```
### jQuery `.data()` → `dataset`
jQuery `.data('foo-bar')` auto-converts to camelCase. The native `dataset` API does the same:
```javascript
// jQuery
$el.data('crop-data'); // reads data-crop-data attribute
$el.data('crop-data', val); // sets data-crop-data attribute
// Native
el.dataset.cropData; // reads data-crop-data (auto camelCase)
el.dataset.cropData = val; // sets data-crop-data
```
### XSS Prevention
Never use `insertAdjacentHTML` or `innerHTML` with interpolated values. This triggers CodeQL `js/xss-through-dom` alerts:
```javascript
// DANGEROUS
container.insertAdjacentHTML('beforeend', `<span>${userValue}</span>`);
// SAFE
const span = document.createElement('span');
span.textContent = userValue;
container.appendChild(span);
```
## Post-Migration Verification
### Verification Checklist
- [ ] All custom plugins converted and working
- [ ] Toolbar configuration matches requirements
- [ ] Keyboard shortcuts functional
- [ ] Link browser integration working
- [ ] Image handling correct
- [ ] Table editing functional
- [ ] Existing content renders correctly
- [ ] New content saves properly
- [ ] Processing rules sanitize correctly
- [ ] Frontend output valid HTML
- [ ] Accessibility compliance maintained
- [ ] Performance acceptable
---
## CKEditor 5 version timeline in TYPO3
| TYPO3 | CKEditor 5 | Notes |
|---|---|---|
| v12.4 LTS | 41.x–42.x | Initial CKE5 integration |
| v13.4 LTS | 41.x–42.x | Feature parity with v12 |
| **v14.3 LTS** | **47.0.0** | Major jump |
### v14 changes to watch
- **Context-aware theming (dark/light) enabled by default** (Breaking [#106964](https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/14.0/Breaking-106964-MakeCkeditorContextAwareByDefault.html)). If you ship a custom RTE preset with hardcoded CSS colors, they may now clash with the backend theme. Prefer CSS custom properties referencing `--typo3-editor-*` tokens.
- **CKEditor 5 v47** brings the Collaboration / Track Changes / Import-from-Word plugin architecture to maturity; none are bundled in TYPO3 core, but if you vendor them, match the 47.x line.
- **PSR-14 `AfterRichtextConfigurationPreparedEvent`** (Feature [#107322](https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/14.0/Feature-107322-IntroduceAfterRichtextConfigurationPreparedEventAfterRteConfigurationIsPrepared.html)) replaces the informal hook previously used to tweak RTE config at runtime.
- **RTE in EXT:form** (Feature [#108966](https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/14.2/Feature-108966-AddCKEditor5SupportForEXTformRichtextElement.html), v14.2+) — CKE5 is now available inside Form Framework `richtext` elements. RTE presets used there must satisfy form-context content rules.
### Jump from v13 (41/42) to v14 (47)
Breaking API changes accumulate across CKE5 41 → 47. Consult the [CKEditor 5 migration docs](https://ckeditor.com/docs/ckeditor5/latest/updating/guides/migration.html) for each major between your source and target. The [TYPO3 Core CKEditor 5 Integration chapter](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ApiOverview/Rte/ConfigurationReference/Index.html) documents the subset used by core plugins and preset YAML conventions.
### Verification steps for a v14 RTE preset
- [ ] RTE renders without console errors in both light and dark backend modes
- [ ] Preset YAML loads without warnings under v14's schema validation
- [ ] Custom plugins declare compatibility with CKEditor 5 v47 API
- [ ] Inline and block widgets respect the new `editor.editing.view.scrollToTheSelection()` behavior
- [ ] `AfterRichtextConfigurationPreparedEvent` (PSR-14) listeners replace any prior use of the older `BeforeRichtextConfigurationPreparedEvent` or custom `getConfiguration` overrides
- [ ] EXT:form integration tested if the preset is used in forms
references/plugin-development.md
# CKEditor 5 Plugin Development for TYPO3
For plugin scaffolding (class structure, schema, converters, commands,
toolbar UI), see the
[CKEditor 5 plugin development guide](https://ckeditor.com/docs/ckeditor5/latest/framework/plugins/plugins.html)
or the Quick Reference in [SKILL.md](../SKILL.md). Before writing upcast/downcast
converters, also read [ckeditor5-architecture.md](ckeditor5-architecture.md#pitfall-view-elements-are-not-dom-elements)
("Pitfall: View Elements Are Not DOM Elements") -- SonarCloud's `javascript:S7761` is a
false positive on `getAttribute('data-*')` calls inside converter
callbacks. This file covers TYPO3-specific plugin wiring and other
gotchas found building CKE5 plugins for TYPO3.
## TYPO3 Integration
### Bundle for TYPO3
```javascript
// Resources/Public/JavaScript/Ckeditor/my-plugin-bundle.js
import MyPlugin from './Plugins/MyPlugin.js';
import MyPluginEditing from './Plugins/MyPluginEditing.js';
import MyPluginUI from './Plugins/MyPluginUI.js';
import MyPluginCommand from './Plugins/MyPluginCommand.js';
// Export all components
export { MyPlugin, MyPluginEditing, MyPluginUI, MyPluginCommand };
// Default export for TYPO3 import
export default { MyPlugin };
```
### Registration in ext_localconf.php
```php
<?php
// ext_localconf.php
defined('TYPO3') or die();
// Register CKEditor 5 plugin
$GLOBALS['TYPO3_CONF_VARS']['RTE']['CKEditor5']['plugins']['my-plugin'] = [
'entryPoint' => 'EXT:my_extension/Resources/Public/JavaScript/Ckeditor/my-plugin-bundle.js',
'stylesheets' => [
'EXT:my_extension/Resources/Public/Css/Ckeditor/my-plugin.css',
],
];
```
### YAML Configuration
```yaml
# Configuration/RTE/Default.yaml
editor:
config:
toolbar:
items:
- heading
- '|'
- bold
- italic
- '|'
- myPluginBox # Button
- myPluginDropdown # Dropdown
- myPluginHighlight
importModules:
- '@vendor/my_extension/ckeditor/my-plugin-bundle.js'
processing:
allowTags:
- div
- mark
# ... other tags
allowAttributes:
- { attribute: 'class', elements: ['div', 'mark'] }
- { attribute: 'data-type', elements: 'div' }
- { attribute: 'data-title', elements: 'div' }
```
## Consumable API - Preventing Duplicate Processing
**Critical pattern for upcast converters** that need to prevent other converters (like GHS - General HTML Support) from processing the same element.
### The Problem: Duplicate Elements
When multiple converters can handle the same HTML element, you get duplicate output:
```html
<!-- Input: linked image -->
<a href="/page"><img src="image.jpg"></a>
<!-- Bug: GHS preserves <a> because your converter didn't consume it -->
<a href="/page"><a href="/page"><img src="image.jpg"></a></a>
```
### The Solution: test() Before consume()
**Always use `consumable.test()` before `consumable.consume()`** to prevent regressions:
```javascript
// ❌ BAD: Consume without testing - may silently fail
conversion.for('upcast').add(dispatcher => {
dispatcher.on('element:a', (evt, data, conversionApi) => {
const { consumable, writer } = conversionApi;
const viewElement = data.viewItem;
// This might fail if another converter already consumed it!
consumable.consume(viewElement, { name: true });
// ... rest of conversion
});
});
// ✅ GOOD: Test first, then consume - prevents race conditions
conversion.for('upcast').add(dispatcher => {
dispatcher.on('element:a', (evt, data, conversionApi) => {
const { consumable, writer } = conversionApi;
const viewElement = data.viewItem;
// Test if element is available for conversion
if (!consumable.test(viewElement, { name: true })) {
return; // Another converter already handled this
}
// Now safe to consume
consumable.consume(viewElement, { name: true });
// ... rest of conversion
});
});
```
### Why test() Matters
1. **Prevents silent failures**: `consume()` returns false if already consumed, but you might not check
2. **Enables proper converter chaining**: Multiple converters can cooperate without conflicts
3. **Avoids duplicate elements**: GHS and other catch-all converters won't process consumed elements
4. **Race condition prevention**: Between `test()` and `consume()`, another converter could consume attributes (but not name)
### Real-World Bug (Issue #565)
```javascript
// Bug: Early return without consuming caused GHS to create duplicate <a>
if (!imgElement) {
return null; // <a> was NOT consumed - GHS preserves it!
}
// Fix: Always consume the element before returning
if (!consumable.test(viewElement, { name: true }) ||
!consumable.test(imgElement, { name: true })) {
return null;
}
consumable.consume(viewElement, { name: true });
consumable.consume(imgElement, { name: true });
```
### Testing for Pre-Consumed Elements
Always test that your converter correctly handles pre-consumed elements:
```javascript
it('returns null when anchor is pre-consumed', () => {
const { anchor, img } = createLinkedImageView('https://example.com', {});
// Simulate another converter consuming the element first
conversionApi.consumable.consume(anchor, { name: true });
const result = linkedImageUpcastConverter(anchor, conversionApi);
expect(result).toBeNull();
});
```
## Native DOM Patterns for CKEditor Plugin Dialogs
CKEditor 5 plugins that open TYPO3 backend dialogs (e.g., image manipulation, link browser) must use native DOM -- never jQuery. TYPO3's `rte_ckeditor` sysext already has zero jQuery, and backend JS can drop jQuery without deprecation notice.
### Dialog Element Access
```javascript
// jQuery (old)
const $dialog = dialog.$el;
$dialog.find('.my-class');
// Native DOM (new)
const dialogEl = dialog.el; // HTMLElement, not jQuery object
dialogEl.querySelector('.my-class');
```
### DOM Builder Helper Pattern
Replace jQuery DOM construction with a small helper:
```javascript
/**
* Create an element, set className, optionally append to parent.
*/
function h(tag, className, parent) {
const el = document.createElement(tag);
if (className) el.className = className;
if (parent) parent.appendChild(el);
return el;
}
// Usage
const wrapper = h('div', 'image-manipulation');
const row = h('div', 'row', wrapper);
const label = h('label', 'form-label', row);
label.textContent = 'Width';
```
**Security:** Never use `insertAdjacentHTML` with interpolated values -- this triggers CodeQL `js/xss-through-dom` alerts. Always use `createElement` + `textContent` for user-visible strings.
### Promise Instead of $.Deferred
```javascript
// jQuery (old)
const deferred = $.Deferred();
// ... later
deferred.resolve(result);
return deferred.promise();
// Native (new) -- extract resolve/reject for later use
let resolveFn, rejectFn;
const promise = new Promise((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
});
// ... later, in a callback or async operation:
if (operationSuccessful) {
resolveFn(result);
} else {
rejectFn(error);
}
return promise;
```
### fetch() Instead of $.getJSON
```javascript
// jQuery (old)
$.getJSON(url).done(data => { ... }).fail(err => { ... });
// Native (new)
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// ... do something with data
} catch (error) {
// ... handle network errors and other issues
}
```
### Event Listeners
```javascript
// jQuery (old)
$element.on('click', handler);
$element.off('click', handler);
// Native (new)
element.addEventListener('click', handler);
element.removeEventListener('click', handler);
```
### Cross-Iframe DOM Access
CKEditor image plugins often interact with iframes (e.g., image manipulation previews):
```javascript
// jQuery (old)
const $iframe = dialog.$el.find('iframe');
const $img = $iframe.contents().find('img');
$img.data('crop-data');
// Native (new)
const iframe = dialogEl.querySelector('iframe');
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
const img = iframeDoc.querySelector('img');
img.dataset.cropData; // .data('crop-data') → dataset.cropData (camelCase)
```
**Note:** jQuery `.data('foo-bar')` maps to `dataset.fooBar` -- jQuery auto-converts kebab-case to camelCase via the `dataset` API.
### Mousewheel Event
```javascript
// jQuery (old)
$element.on('mousewheel', function(e) {
e.preventDefault();
const delta = e.originalEvent.wheelDelta;
zoom += delta > 0 ? 0.1 : -0.1;
});
// Native (new) -- 'wheel' event with inverted deltaY
element.addEventListener('wheel', (e) => {
e.preventDefault();
// deltaY is POSITIVE for scroll-down (opposite of old wheelDelta)
zoom += e.deltaY < 0 ? 0.1 : -0.1;
}, { passive: false }); // passive: false required to allow preventDefault()
```
references/typo3-integration.md
# CKEditor 5 TYPO3 Integration
## Overview
TYPO3 v12+ uses CKEditor 5 as the default Rich Text Editor. Integration is handled through:
- YAML-based RTE presets
- Custom module bundling
- PHP configuration hooks
- Processing rules for HTML sanitization
## Configuration Structure
### Directory Layout
```
EXT:my_extension/
├── Configuration/
│ └── RTE/
│ ├── Default.yaml # Default preset
│ ├── Minimal.yaml # Minimal preset
│ └── Full.yaml # Full-featured preset
├── Resources/
│ └── Public/
│ └── JavaScript/
│ └── Ckeditor/
│ ├── Plugins/
│ │ └── MyPlugin.js
│ └── my-plugin-bundle.js
└── ext_localconf.php
```
### YAML Configuration
```yaml
# Configuration/RTE/MyPreset.yaml
editor:
config:
# Toolbar configuration
toolbar:
items:
- heading
- '|'
- bold
- italic
- strikethrough
- subscript
- superscript
- '|'
- link
- '|'
- bulletedList
- numberedList
- '|'
- blockQuote
- insertTable
- '|'
- sourceEditing
- '|'
- undo
- redo
# Heading configuration
heading:
options:
- { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' }
- { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' }
- { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' }
- { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' }
- { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }
# Table configuration
table:
contentToolbar:
- tableColumn
- tableRow
- mergeTableCells
- tableProperties
- tableCellProperties
# Link configuration
link:
allowCreatingEmptyLinks: false
defaultProtocol: 'https://'
decorators:
openInNewTab:
mode: manual
label: 'Open in a new tab'
defaultValue: false
attributes:
target: '_blank'
rel: 'noopener noreferrer'
# Style definitions
style:
definitions:
- { name: 'Lead paragraph', element: 'p', classes: ['lead'] }
- { name: 'Info box', element: 'div', classes: ['info-box'] }
- { name: 'Warning box', element: 'div', classes: ['warning-box'] }
# Import custom modules
importModules:
- '@typo3/rte-ckeditor/plugin/typo3-link.js'
- '@typo3/rte-ckeditor/plugin/typo3-image.js'
- '@vendor/my_extension/ckeditor/my-plugin-bundle.js'
# Processing configuration (HTML sanitization)
processing:
mode: default
allowTags:
- a
- abbr
- b
- blockquote
- br
- caption
- cite
- code
- col
- colgroup
- dd
- del
- dfn
- div
- dl
- dt
- em
- figcaption
- figure
- h1
- h2
- h3
- h4
- h5
- h6
- hr
- i
- img
- ins
- kbd
- li
- mark
- ol
- p
- pre
- q
- s
- samp
- small
- span
- strong
- sub
- sup
- table
- tbody
- td
- tfoot
- th
- thead
- tr
- u
- ul
- var
allowAttributes:
# Global attributes
- { attribute: 'class', elements: '*' }
- { attribute: 'id', elements: '*' }
- { attribute: 'title', elements: '*' }
- { attribute: 'lang', elements: '*' }
- { attribute: 'dir', elements: '*' }
# Link attributes
- { attribute: 'href', elements: 'a' }
- { attribute: 'target', elements: 'a' }
- { attribute: 'rel', elements: 'a' }
- { attribute: 'download', elements: 'a' }
# Image attributes
- { attribute: 'src', elements: 'img' }
- { attribute: 'alt', elements: 'img' }
- { attribute: 'width', elements: 'img' }
- { attribute: 'height', elements: 'img' }
- { attribute: 'loading', elements: 'img' }
# Table attributes
- { attribute: 'colspan', elements: ['td', 'th'] }
- { attribute: 'rowspan', elements: ['td', 'th'] }
- { attribute: 'scope', elements: 'th' }
# Transform tags
transformTags:
b: strong
i: em
# Deny tags explicitly
denyTags:
- script
- style
- iframe
- object
- embed
```
## PHP Registration
### Preset Registration
```php
<?php
// ext_localconf.php
defined('TYPO3') or die();
// Register RTE presets
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['my_extension_default'] =
'EXT:my_extension/Configuration/RTE/Default.yaml';
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['my_extension_minimal'] =
'EXT:my_extension/Configuration/RTE/Minimal.yaml';
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['my_extension_full'] =
'EXT:my_extension/Configuration/RTE/Full.yaml';
```
### Custom Plugin Registration
```php
<?php
// ext_localconf.php
// Register CKEditor 5 plugin with stylesheets
$GLOBALS['TYPO3_CONF_VARS']['RTE']['CKEditor5']['plugins']['my-plugin'] = [
'entryPoint' => 'EXT:my_extension/Resources/Public/JavaScript/Ckeditor/my-plugin-bundle.js',
'stylesheets' => [
'EXT:my_extension/Resources/Public/Css/Ckeditor/my-plugin.css',
],
];
// Alternative: Register via PageTsConfig for specific pages
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addPageTSConfig('
RTE.default.preset = my_extension_default
');
```
## TCA Configuration
### RTE in TCA
```php
<?php
// Configuration/TCA/Overrides/tt_content.php
$GLOBALS['TCA']['tt_content']['columns']['bodytext']['config'] = [
'type' => 'text',
'enableRichtext' => true,
'richtextConfiguration' => 'my_extension_default',
];
// Conditional RTE configuration
$GLOBALS['TCA']['tt_content']['types']['textmedia']['columnsOverrides'] = [
'bodytext' => [
'config' => [
'enableRichtext' => true,
'richtextConfiguration' => 'my_extension_full',
],
],
];
```
### Custom Content Elements
```php
<?php
// Configuration/TCA/tx_myextension_content.php
return [
'ctrl' => [
'title' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang_db.xlf:tx_myextension_content',
'label' => 'title',
// ... other ctrl settings
],
'columns' => [
'content' => [
'label' => 'Content',
'config' => [
'type' => 'text',
'enableRichtext' => true,
'richtextConfiguration' => 'my_extension_default',
'rows' => 15,
],
],
],
];
```
## JavaScript Module System
### ES Module Structure
```javascript
// Resources/Public/JavaScript/Ckeditor/Plugins/MyPlugin.js
import { Plugin } from '@ckeditor/ckeditor5-core';
import { ButtonView } from '@ckeditor/ckeditor5-ui';
export default class MyPlugin extends Plugin {
static get pluginName() {
return 'MyPlugin';
}
init() {
const editor = this.editor;
// Add toolbar button
editor.ui.componentFactory.add('myPluginButton', locale => {
const buttonView = new ButtonView(locale);
buttonView.set({
label: 'My Plugin',
tooltip: true,
withText: true
});
buttonView.on('execute', () => {
// Plugin action
console.log('My plugin executed');
});
return buttonView;
});
}
}
```
### Bundle Entry Point
```javascript
// Resources/Public/JavaScript/Ckeditor/my-plugin-bundle.js
import MyPlugin from './Plugins/MyPlugin.js';
// Export for TYPO3 to register
export default {
MyPlugin
};
// Or export individual plugins
export { MyPlugin };
```
### Import Map Configuration
```yaml
# Configuration/RTE/MyPreset.yaml
editor:
config:
importModules:
# TYPO3 core modules use @ prefix
- '@typo3/rte-ckeditor/plugin/typo3-link.js'
# Custom modules use vendor prefix
- '@vendor/my_extension/ckeditor/my-plugin-bundle.js'
```
## Processing Pipeline
### Understanding RTE Processing
```
User Input (Browser)
↓
CKEditor 5 Model
↓
CKEditor 5 View (HTML)
↓
TYPO3 Processing (HTMLParser)
↓
Database Storage
↓
TYPO3 Processing (Frontend)
↓
Frontend Output
```
### Custom Processing
```yaml
# Advanced processing configuration
processing:
mode: default
# Allow specific data attributes
allowAttributes:
- { attribute: 'data-*', elements: '*' }
# Custom transformations
HTMLparser_db:
# Settings applied when saving to database
allowTags: 'p,br,strong,em,a,ul,ol,li'
denyTags: 'script,style'
HTMLparser_rte:
# Settings applied when loading into RTE
stripEmptyTags: 1
exitHTMLparser_db:
# Settings after processing for database
keepNonMatchedTags: 0
```
### PHP Processing Hook
```php
<?php
// Classes/EventListener/RteProcessingListener.php
namespace Vendor\MyExtension\EventListener;
use TYPO3\CMS\Core\Html\Event\BrokenLinkAnalysisEvent;
final class RteProcessingListener
{
public function __invoke(BrokenLinkAnalysisEvent $event): void
{
// Custom link processing
$content = $event->getContent();
// Modify content
$modifiedContent = $this->processContent($content);
$event->setContent($modifiedContent);
}
private function processContent(string $content): string
{
// Custom processing logic
return $content;
}
}
```
### TypoScript parseFunc externalBlocks
**Critical:** `externalBlocks` requires a TWO-part configuration:
1. `externalBlocks = tag1, tag2` — comma-separated list of tag names to split on
2. `externalBlocks.tag1 { ... }` — per-tag processing configuration
Sub-properties for tags NOT in the list are **silently ignored** (common source of dead code).
TYPO3 core default list: `article, aside, blockquote, div, dd, dl, footer, header, nav, ol, section, table, ul, pre, figure, figcaption`
**`a` tags must NOT be in externalBlocks** — `externalBlocks` splits content by regex, so extracting `<a>` from inside `<p>` produces invalid HTML fragments. Use `tags.a` instead, which leverages depth-first processing: inner `tags.img` fires before outer `tags.a`, so the image is already processed when the link handler runs.
### PHP DOMDocument::loadHTML() and UTF-8
`DOMDocument::loadHTML()` defaults to **ISO-8859-1**, silently corrupting multi-byte UTF-8 characters (German umlauts ä/ö/ü/ß, French accents, etc.).
```php
// WRONG — corrupts UTF-8
$dom->loadHTML('<div>' . $html . '</div>');
// CORRECT — preserves UTF-8
$dom->loadHTML(
'<?xml encoding="UTF-8"><div>' . $html . '</div>',
LIBXML_NONET | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD,
);
```
This affects any CKEditor image extension parsing RTE HTML content (figcaptions, alt text, link titles). PHP 8.4+ offers `Dom\HTMLDocument::createFromString()` as a modern alternative.
## Link Browser Integration
### TypoLink Format
**Critical:** TYPO3's TypoLink format has a specific parameter order:
```
url target class "title" additionalParams
```
Example: `t3://page?uid=1 _blank link-class "Link Title" &L=1&type=123`
| Position | Parameter | Description |
|----------|-----------|-------------|
| 1 | URL | Link target (t3://page, https://, file:...) |
| 2 | Target | Window target (_blank, _self, _top, _parent) |
| 3 | Class | CSS class for the link element |
| 4 | Title | Link title (must be quoted if contains spaces) |
| 5 | Params | Additional URL parameters (&L=1&type=123) |
### TYPO3 Link Handler
```yaml
# Configuration/RTE/Default.yaml
editor:
config:
typo3link:
routeType: page
additionalAttributes:
- 'data-link-type'
# Configure which link types are available
typo3LinkConfig:
allowedTypes:
- page
- file
- folder
- url
- email
- telephone
```
### FormEngine Link Browser (Not RTE-Specific)
When building link browser URLs for custom image dialogs, use FormEngine-style parameters
instead of RTE-specific adapters:
```php
// Generate link browser URL for image linking
$linkBrowserUrl = $this->uriBuilder->buildUriFromRoute(
'wizard_link',
[
'P' => [
'table' => 'tt_content',
'uid' => 0, // No specific record; page context via pid
'pid' => $pid,
'field' => 'bodytext',
'formName' => 'typo3image_linkform',
'itemName' => 'typo3image_link',
'currentValue' => $currentValue,
'currentSelectedValues' => $currentValue,
'params' => [
'blindLinkOptions' => '',
'blindLinkFields' => '',
],
],
],
);
```
### URL Parameter Handling
When appending additional parameters to URLs, handle query strings and fragments correctly:
```php
/**
* Append params to URL, handling existing query strings and fragments.
*
* @param string $url Base URL (may have query string and/or fragment)
* @param string $params Additional parameters (&L=1 or ?L=1 or L=1)
*/
public function getUrlWithParams(string $url, ?string $params): string
{
if ($params === null || $params === '') {
return $url;
}
$fragment = '';
// Extract fragment if present (params go before fragment)
$fragmentPos = strpos($url, '#');
if ($fragmentPos !== false) {
$fragment = substr($url, $fragmentPos);
$url = substr($url, 0, $fragmentPos);
}
// Normalize params based on existing query string
if (str_contains($url, '?')) {
// URL has query - ensure params start with &
if (str_starts_with($params, '?')) {
$params = '&' . substr($params, 1);
} elseif (!str_starts_with($params, '&')) {
$params = '&' . $params;
}
} else {
// URL has no query - ensure params start with ?
if (str_starts_with($params, '&')) {
$params = '?' . substr($params, 1);
} elseif (!str_starts_with($params, '?')) {
$params = '?' . $params;
}
}
return $url . $params . $fragment;
}
```
### Custom Link Handler
```php
<?php
// Classes/LinkHandler/MyLinkHandler.php
namespace Vendor\MyExtension\LinkHandler;
use TYPO3\CMS\Recordlist\LinkHandler\AbstractLinkHandler;
final class MyLinkHandler extends AbstractLinkHandler
{
protected $linkAttributes = ['data-my-attr'];
public function canHandleLink(array $linkParts): bool
{
return isset($linkParts['type']) && $linkParts['type'] === 'mylink';
}
public function formatCurrentUrl(): string
{
return 'My Link: ' . $this->linkParts['url'];
}
public function render(ServerRequestInterface $request): string
{
// Render link browser tab content
return '<div>Custom link selection interface</div>';
}
}
```
## Image Integration
### Image Plugin Configuration
```yaml
# Configuration/RTE/Default.yaml
editor:
config:
# TYPO3 image integration
typo3image:
routeType: image
image:
# Image toolbar
toolbar:
- imageTextAlternative
- toggleImageCaption
- '|'
- imageStyle:block
- imageStyle:side
- '|'
- linkImage
# Image styles
styles:
options:
- { name: 'block', title: 'Centered', icon: 'objectCenter', modelElements: ['imageBlock'] }
- { name: 'side', title: 'Side', icon: 'objectRight', modelElements: ['imageBlock'], className: 'image-style-side' }
# Resize options
resizeOptions:
- { name: 'imageResize:original', value: null, label: 'Original' }
- { name: 'imageResize:50', value: '50', label: '50%' }
- { name: 'imageResize:75', value: '75', label: '75%' }
```
## PageTSConfig Integration
### Per-Page Configuration
```typoscript
# Page TSConfig
RTE {
default {
preset = my_extension_default
}
# Table-specific configuration
config.tx_news_domain_model_news {
bodytext {
preset = my_extension_minimal
}
}
# Field-specific configuration
config.tt_content.bodytext {
types {
text {
preset = my_extension_default
}
textmedia {
preset = my_extension_full
}
}
}
}
```
## Debugging
### Enable Debug Mode
```php
<?php
// In AdditionalConfiguration.php
$GLOBALS['TYPO3_CONF_VARS']['BE']['debug'] = true;
// Check RTE configuration
// Access: /typo3/module/tools/configuration
// Look under: $GLOBALS['TYPO3_CONF_VARS']['RTE']
```
### JavaScript Debugging
```javascript
// In browser console
// Access CKEditor instance
const editors = CKEDITOR.instances;
console.log(editors);
// Or find by element
const editor = CKEDITOR.instances['bodytext'];
console.log(editor.config);
console.log(editor.plugins.getAll());
```
## Best Practices
### Performance
1. **Minimal Presets**: Create minimal presets for simple text fields
2. **Lazy Loading**: Use importModules only when needed
3. **Bundle Optimization**: Bundle related plugins together
### Maintainability
1. **Preset Inheritance**: Create base presets and extend them
2. **Consistent Naming**: Use clear naming for presets and plugins
3. **Documentation**: Document custom plugins and configurations
### Security
1. **Strict Processing**: Configure processing rules carefully
2. **Attribute Whitelist**: Only allow necessary attributes
3. **Content Sanitization**: Always sanitize on output
scripts/verify-ckeditor5.sh
#!/bin/bash
# CKEditor 5 TYPO3 Integration Verification Script
# Verifies CKEditor 5 plugin structure and configuration
set -e
EXTENSION_DIR="${1:-.}"
ERRORS=0
WARNINGS=0
echo "=== CKEditor 5 TYPO3 Integration Verification ==="
echo "Extension: $EXTENSION_DIR"
echo ""
# Check for RTE configuration
echo "=== Checking RTE Configuration ==="
if [[ -d "$EXTENSION_DIR/Configuration/RTE" ]]; then
YAML_FILES=$(find "$EXTENSION_DIR/Configuration/RTE" -name "*.yaml" 2>/dev/null | wc -l)
if [[ $YAML_FILES -gt 0 ]]; then
echo "✅ Found $YAML_FILES RTE YAML configuration file(s)"
# Check YAML structure
for yaml in "$EXTENSION_DIR/Configuration/RTE"/*.yaml; do
if [[ -f "$yaml" ]]; then
echo " Checking: $(basename "$yaml")"
# Check for required sections
if grep -q "^editor:" "$yaml" 2>/dev/null; then
echo " ✅ Has 'editor' configuration"
else
echo " ⚠️ Missing 'editor' section"
((WARNINGS++))
fi
if grep -q "^processing:" "$yaml" 2>/dev/null; then
echo " ✅ Has 'processing' configuration"
else
echo " ⚠️ Missing 'processing' section (HTML sanitization)"
((WARNINGS++))
fi
# Check for toolbar configuration
if grep -q "toolbar:" "$yaml" 2>/dev/null; then
echo " ✅ Has toolbar configuration"
else
echo " ⚠️ Missing toolbar configuration"
((WARNINGS++))
fi
# Check for importModules
if grep -q "importModules:" "$yaml" 2>/dev/null; then
echo " ✅ Has module imports configured"
fi
fi
done
else
echo "⚠️ No YAML configuration files found in Configuration/RTE/"
((WARNINGS++))
fi
else
echo "⚠️ No Configuration/RTE directory found"
((WARNINGS++))
fi
# Check for CKEditor JavaScript plugins
echo ""
echo "=== Checking CKEditor 5 Plugins ==="
JS_DIRS=("Resources/Public/JavaScript/Ckeditor" "Resources/Public/JavaScript/CKEditor" "Resources/Public/JavaScript/ckeditor")
FOUND_JS_DIR=""
for dir in "${JS_DIRS[@]}"; do
if [[ -d "$EXTENSION_DIR/$dir" ]]; then
FOUND_JS_DIR="$EXTENSION_DIR/$dir"
break
fi
done
if [[ -n "$FOUND_JS_DIR" ]]; then
echo "✅ Found CKEditor JavaScript directory: $FOUND_JS_DIR"
JS_FILES=$(find "$FOUND_JS_DIR" -name "*.js" 2>/dev/null | wc -l)
if [[ $JS_FILES -gt 0 ]]; then
echo "✅ Found $JS_FILES JavaScript file(s)"
# Check for ES module patterns
for jsfile in $(find "$FOUND_JS_DIR" -name "*.js" 2>/dev/null); do
filename=$(basename "$jsfile")
echo " Checking: $filename"
# Check for ES module imports
if grep -q "import.*from" "$jsfile" 2>/dev/null; then
echo " ✅ Uses ES module imports"
else
echo " ⚠️ No ES module imports found"
((WARNINGS++))
fi
# Check for Plugin class pattern
if grep -q "extends Plugin" "$jsfile" 2>/dev/null; then
echo " ✅ Uses CKEditor 5 Plugin class"
fi
# Check for Command pattern
if grep -q "extends Command" "$jsfile" 2>/dev/null; then
echo " ✅ Uses CKEditor 5 Command class"
fi
# Check for export
if grep -q "export" "$jsfile" 2>/dev/null; then
echo " ✅ Has exports"
else
echo " ⚠️ No exports found - may not be loadable"
((WARNINGS++))
fi
done
else
echo "⚠️ No JavaScript files found"
((WARNINGS++))
fi
else
echo "ℹ️ No CKEditor JavaScript directory found (optional)"
fi
# Check for CSS stylesheets
echo ""
echo "=== Checking CKEditor 5 Stylesheets ==="
CSS_DIRS=("Resources/Public/Css/Ckeditor" "Resources/Public/Css/CKEditor" "Resources/Public/Css/ckeditor")
FOUND_CSS_DIR=""
for dir in "${CSS_DIRS[@]}"; do
if [[ -d "$EXTENSION_DIR/$dir" ]]; then
FOUND_CSS_DIR="$EXTENSION_DIR/$dir"
break
fi
done
if [[ -n "$FOUND_CSS_DIR" ]]; then
echo "✅ Found CKEditor CSS directory: $FOUND_CSS_DIR"
CSS_FILES=$(find "$FOUND_CSS_DIR" -name "*.css" 2>/dev/null | wc -l)
echo "✅ Found $CSS_FILES CSS file(s)"
else
echo "ℹ️ No CKEditor CSS directory found (optional)"
fi
# Check ext_localconf.php for plugin registration
echo ""
echo "=== Checking Plugin Registration ==="
if [[ -f "$EXTENSION_DIR/ext_localconf.php" ]]; then
# Check for RTE preset registration
if grep -q "RTE.*Presets" "$EXTENSION_DIR/ext_localconf.php" 2>/dev/null; then
echo "✅ RTE preset registration found"
else
echo "ℹ️ No RTE preset registration in ext_localconf.php"
fi
# Check for CKEditor 5 plugin registration
if grep -q "CKEditor5.*plugins" "$EXTENSION_DIR/ext_localconf.php" 2>/dev/null; then
echo "✅ CKEditor 5 plugin registration found"
else
echo "ℹ️ No CKEditor 5 plugin registration in ext_localconf.php"
fi
else
echo "⚠️ No ext_localconf.php found"
((WARNINGS++))
fi
# Check for TCA with RTE configuration
echo ""
echo "=== Checking TCA RTE Configuration ==="
if [[ -d "$EXTENSION_DIR/Configuration/TCA" ]]; then
RTE_TCA=$(grep -rl "enableRichtext" "$EXTENSION_DIR/Configuration/TCA" 2>/dev/null | wc -l)
if [[ $RTE_TCA -gt 0 ]]; then
echo "✅ Found $RTE_TCA TCA file(s) with RTE configuration"
# Check for richtextConfiguration
PRESET_CONFIG=$(grep -rl "richtextConfiguration" "$EXTENSION_DIR/Configuration/TCA" 2>/dev/null | wc -l)
if [[ $PRESET_CONFIG -gt 0 ]]; then
echo "✅ Custom RTE preset assignments found"
fi
else
echo "ℹ️ No TCA files with RTE configuration found"
fi
else
echo "ℹ️ No TCA directory found"
fi
# Check for CKEditor 4 remnants (migration check)
echo ""
echo "=== Migration Check (CKEditor 4 Remnants) ==="
CKE4_PATTERNS=0
# Check for old widget patterns in JS
if [[ -n "$FOUND_JS_DIR" ]]; then
OLD_PATTERNS=$(grep -rl "CKEDITOR\." "$FOUND_JS_DIR" 2>/dev/null | wc -l)
if [[ $OLD_PATTERNS -gt 0 ]]; then
echo "⚠️ Found CKEditor 4 global namespace usage in $OLD_PATTERNS file(s)"
((WARNINGS++))
((CKE4_PATTERNS++))
fi
fi
# Check for old configuration patterns
if [[ -d "$EXTENSION_DIR/Configuration/RTE" ]]; then
OLD_YAML=$(grep -rl "extraPlugins\|removePlugins\|allowedContent" "$EXTENSION_DIR/Configuration/RTE" 2>/dev/null | wc -l)
if [[ $OLD_YAML -gt 0 ]]; then
echo "⚠️ Found CKEditor 4 configuration patterns in YAML"
((WARNINGS++))
((CKE4_PATTERNS++))
fi
fi
# Check for old PageTSConfig patterns
if [[ -d "$EXTENSION_DIR/Configuration/TsConfig" ]] || [[ -d "$EXTENSION_DIR/Configuration/TSconfig" ]]; then
OLD_TS=$(grep -rl "RTE.default.proc\|RTE.default.buttons" "$EXTENSION_DIR/Configuration" 2>/dev/null | wc -l)
if [[ $OLD_TS -gt 0 ]]; then
echo "⚠️ Found CKEditor 4 PageTSConfig patterns"
((WARNINGS++))
((CKE4_PATTERNS++))
fi
fi
if [[ $CKE4_PATTERNS -eq 0 ]]; then
echo "✅ No CKEditor 4 patterns detected"
fi
# Check processing configuration
echo ""
echo "=== Processing Configuration Check ==="
if [[ -d "$EXTENSION_DIR/Configuration/RTE" ]]; then
for yaml in "$EXTENSION_DIR/Configuration/RTE"/*.yaml; do
if [[ -f "$yaml" ]]; then
# Check for allowTags
if grep -q "allowTags:" "$yaml" 2>/dev/null; then
echo "✅ $(basename "$yaml"): Has allowTags configuration"
fi
# Check for allowAttributes
if grep -q "allowAttributes:" "$yaml" 2>/dev/null; then
echo "✅ $(basename "$yaml"): Has allowAttributes configuration"
fi
# Check for dangerous tags not denied
if grep -q "script\|iframe\|object" "$yaml" 2>/dev/null; then
# Check if they're in denyTags
if grep -q "denyTags:" "$yaml" 2>/dev/null; then
echo "✅ $(basename "$yaml"): Has denyTags configuration"
else
echo "⚠️ $(basename "$yaml"): May allow dangerous tags without denyTags"
((WARNINGS++))
fi
fi
fi
done
fi
# Check for documentation
echo ""
echo "=== Documentation Check ==="
if [[ -f "$EXTENSION_DIR/README.md" ]] || [[ -f "$EXTENSION_DIR/Documentation/Index.rst" ]]; then
echo "✅ Documentation found"
else
echo "⚠️ No README.md or Documentation/Index.rst found"
((WARNINGS++))
fi
# Summary
echo ""
echo "=== Summary ==="
echo "Errors: $ERRORS"
echo "Warnings: $WARNINGS"
if [[ $ERRORS -gt 0 ]]; then
echo "❌ Verification FAILED"
exit 1
elif [[ $WARNINGS -gt 3 ]]; then
echo "⚠️ Verification completed with significant warnings"
exit 0
else
echo "✅ Verification PASSED"
exit 0
fi
SKILL.md
---
name: typo3-ckeditor5
description: "Use when developing CKEditor 5 custom plugins for TYPO3 v12+ (v14.3 LTS bundles CKE5 v47; v13 shipped 41-42), configuring RTE presets, migrating from CKEditor 4, customizing toolbars, dark/light-mode context (on by default in v14, #106964), or fixing rich text editing issues. Triggers: CKE5, RTE config, YAML preset, editor plugin, jQuery removal, backend JS, CKEditor 47."
---
# TYPO3 CKEditor 5 Skill
CKEditor 5 integration patterns for TYPO3: custom plugins, configuration, and migration.
## Expertise Areas
- **Architecture**: Plugin system, schema/conversion, commands, UI components
- **TYPO3 Integration**: YAML configuration, plugin registration, content elements
- **Migration**: CKEditor 4->5 complete rewrite (no compatibility layer exists)
## Reference Files
- `references/ckeditor5-architecture.md` - Gotchas: figcaption, view-vs-DOM elements
- `references/typo3-integration.md` - TYPO3-specific patterns
- `references/plugin-development.md` - TYPO3 wiring, consumable API, jQuery-free dialogs
- `references/migration-guide.md` - CKEditor 4->5 migration
## Quick Reference
### Plugin Registration (ext_localconf.php)
```php
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['my_preset'] = 'EXT:my_ext/Configuration/RTE/MyPreset.yaml';
$GLOBALS['TYPO3_CONF_VARS']['SYS']['ckeditor5']['plugins']['my-plugin'] = [
'entryPoint' => 'EXT:my_ext/Resources/Public/JavaScript/Ckeditor/my-plugin.js',
];
```
### Plugin Structure (Editing/UI Split Required)
```
packages/my-plugin/src/
├── myplugin.js # Main: requires Editing + UI
├── mypluginediting.js # Schema, converters, commands
├── mypluginui.js # Toolbar buttons (ButtonView, componentFactory)
└── myplugincommand.js # Command: execute() + refresh()
```
### Key Patterns
```javascript
// Schema: always register with allowIn/allowAttributes
schema.register('myElement', { inheritAllFrom: '$block', allowAttributes: ['type'] });
// Converters: both upcast + downcast required
conversion.for('upcast').elementToElement({ view: { name: 'div', classes: 'my-el' }, model: 'myElement' });
conversion.for('downcast').elementToElement({ model: 'myElement', view: 'div' });
// Command: must implement execute() AND refresh()
class MyCommand extends Command {
refresh() { this.isEnabled = /* check model state */; }
execute() { this.editor.model.change(writer => { /* ... */ }); }
}
```
## jQuery Removal (Critical)
TYPO3 backend JS is dropping jQuery without deprecation period. CKEditor 5 plugins must use native APIs only:
- `querySelector`/`querySelectorAll` instead of `$()`
- `fetch()` + `async/await` instead of `$.ajax`/`$.getJSON`
- `Promise` instead of `$.Deferred`
## Backend Integration
**Property name mismatch is the #1 bug.** Frontend JS must match exact backend response property names.
```javascript
// Backend returns: { content: "...", model: "...", usage: {...} }
const text = result.content; // CORRECT (not result.completion)
```
## Migration (CKE4 -> CKE5)
CKEditor 5 is a complete rewrite -- no compatibility layer. Migration requires full plugin rewrite:
- [ ] Audit CKE4 plugins, map features to CKE5 equivalents
- [ ] Convert `CKEDITOR.plugins.add()` to class-based `extends Plugin`
- [ ] Replace `editor.widgets.add()` with schema + converters + commands
- [ ] Convert PageTSConfig to YAML preset (`Configuration/RTE/*.yaml`)
- [ ] Use ES6 modules (no AMD/CommonJS)
- [ ] Remove all jQuery dependencies
- [ ] Verify backend response property names match frontend usage
## Verification
```bash
./scripts/verify-ckeditor5.sh /path/to/extension
```
---
> **Contributing:** https://github.com/netresearch/typo3-ckeditor5-skill