
Typo3 Ckeditor5
- 39 installs
- 2 repo stars
- Updated August 2, 2026
- netresearch/typo3-ckeditor5-skill
Helps with ai & agent building tasks.
About
typo3-ckeditor5 is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- typo3-ckeditor5
- AI & Agent Building
- AI-coding skill
Typo3 Ckeditor5 by the numbers
- 39 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #8,347 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/typo3-ckeditor5-skill --skill typo3-ckeditor5Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 39 |
|---|---|
| repo stars | ★ 2 |
| Last updated | August 2, 2026 |
| Repository | netresearch/typo3-ckeditor5-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
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- Core MVC, schema, conversionreferences/typo3-integration.md- TYPO3-specific patternsreferences/plugin-development.md- Custom plugin guidereferences/migration-guide.md- CKEditor 4->5 migration
Quick Reference
Plugin Registration (ext_localconf.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
// 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/querySelectorAllinstead of$()fetch()+async/awaitinstead of$.ajax/$.getJSONPromiseinstead of$.Deferred
Backend Integration
Property name mismatch is the #1 bug. Frontend JS must match exact backend response property names.
// 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-basedextends 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
./scripts/verify-ckeditor5.sh /path/to/extension---
Contributing: https://github.com/netresearch/typo3-ckeditor5-skill
# Checkpoints for typo3-ckeditor5 skill
# Validates CKEditor 5 plugin structure and TYPO3 integration
version: 1
skill_id: typo3-ckeditor5
preconditions:
- type: file_exists
target: ext_emconf.php
- type: command
pattern: "find . -path '*/JavaScript/Ckeditor*' -o -path '*/JavaScript/ckeditor*' -o -path '*/RTE/*.yaml' -o -path '*/RTE/*.yml' | head -1 | 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 . -path '*/Configuration/RTE/*.yaml' -o -path '*/Configuration/RTE/*.yml' | head -1 | grep -q ."
severity: warning
desc: "RTE YAML preset should exist in Configuration/RTE/"
- id: CK-05
type: command
pattern: "find . -path '*/Configuration/RTE/*.yaml' -o -path '*/Configuration/RTE/*.yml' | head -1 | xargs grep -q 'editor:'"
severity: warning
desc: "RTE YAML preset should have editor configuration block"
- id: CK-06
type: command
pattern: "find . -path '*/Configuration/RTE/*.yaml' -o -path '*/Configuration/RTE/*.yml' | head -1 | xargs grep -q 'toolbar:'"
severity: info
desc: "RTE YAML preset should configure toolbar items"
# === PLUGIN JS FILES ===
- id: CK-07
type: command
pattern: "find . -path '*/JavaScript/Ckeditor*/*.js' -o -path '*/JavaScript/ckeditor*/*.js' -o -path '*/JavaScript/Ckeditor*/*.ts' | 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 . -path '*/JavaScript/Ckeditor*' -name '*.js' -exec grep -l 'jquery' {} + 2>/dev/null | head -1 | grep -q ."
severity: error
desc: "CKEditor 5 plugins must not import jQuery"
- id: CK-09
type: command
pattern: "! find . -path '*/JavaScript/Ckeditor*' -name '*.js' -exec grep -l '\\$(' {} + 2>/dev/null | head -1 | 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 . -path '*/Configuration/RTE/*.yaml' -o -path '*/Configuration/RTE/*.yml' | xargs grep -l 'CKEditor4' 2>/dev/null | head -1 | 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 . -path '*/Configuration/RTE/*.yaml' -o -path '*/Configuration/RTE/*.yml' | head -1 | xargs grep -q 'processing:' 2>/dev/null || true"
severity: info
desc: "RTE YAML preset should have processing section (allowTags, allowAttributes)"
# === NO $.DEFERRED IN CKEditor CODE ===
- id: CK-12
type: command
pattern: "! find . -path '*/JavaScript/Ckeditor*' -name '*.js' -exec grep -l 'Deferred' {} + 2>/dev/null | head -1 | 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 . -path '*/JavaScript/Ckeditor*' -name '*.js' -exec grep -l '\\$.getJSON\\|\\$.ajax\\|\\$.get\\|\\$.post' {} + 2>/dev/null | head -1 | 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 . -path '*/JavaScript/Ckeditor*' -name '*editing*' -o -path '*/JavaScript/Ckeditor*' -name '*Editing*' | head -1 | grep -q . 2>/dev/null || true"
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"
CKEditor 5 Architecture
Core Concepts
MVC Architecture
CKEditor 5 uses a strict MVC pattern:
Model (Data) ←→ Controller (Commands/Observers) ←→ View (Editing/Data)- Model: Abstract document representation
- View: DOM-like structure for rendering
- Controller: Commands and observers for state changes
Key Components
// Editor instance structure
editor = {
model: Model, // Document data model
editing: {
view: EditingView, // Editable content view
downcast: DowncastDispatcher // Model → View
},
data: {
view: DataView, // Data processing view
upcast: UpcastDispatcher, // View → Model
downcast: DowncastDispatcher // Model → View
},
conversion: ConversionApi, // Unified conversion interface
commands: CommandCollection, // Editor commands
plugins: PluginCollection, // Active plugins
ui: EditorUI // User interface components
}Model Layer
Schema
The schema defines what elements and attributes are allowed:
// Schema registration
schema.register('myElement', {
// Inheritance
inheritAllFrom: '$block', // Inherit from $block
inheritTypesFrom: '$container', // Inherit types only
// Behavior
isBlock: true, // Block element
isObject: true, // Object element (atomic)
isInline: true, // Inline element
isLimit: true, // Cannot be split
isSelectable: true, // Can be selected
isContent: true, // Contains content
// Allowed contexts
allowIn: ['$root', 'tableCell'],
allowChildren: ['$text', 'softBreak'],
// Attributes
allowAttributes: ['class', 'id', 'data-*'],
allowAttributesOf: '$block',
// Content rules
allowContentOf: '$block'
});
// Schema extension
schema.extend('$text', {
allowAttributes: ['myInlineAttribute']
});
// Check schema
const isAllowed = schema.checkChild(position, 'myElement');
const canSetAttribute = schema.checkAttribute(element, 'myAttribute');Built-in Schema Items
// Base items
'$root' // Root element, contains blocks
'$container' // Can contain blocks
'$block' // Block-level element
'$blockObject' // Block that's an atomic object
'$inlineObject' // Inline atomic object
'$text' // Text node
'$clipboardHolder' // Clipboard content holder
// Common elements
'paragraph' // <p> element
'heading1-6' // <h1>-<h6> elements
'blockQuote' // <blockquote> element
'listItem' // List item
'tableCell' // Table cellfigcaption 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.
Conversion System
Upcast (View → Model)
// Element to element
conversion.for('upcast').elementToElement({
view: {
name: 'div',
classes: 'info-box'
},
model: 'infoBox'
});
// Element to element with attributes
conversion.for('upcast').elementToElement({
view: {
name: 'div',
classes: 'info-box',
attributes: {
'data-type': true // Must have this attribute
}
},
model: (viewElement, { writer }) => {
return writer.createElement('infoBox', {
type: viewElement.getAttribute('data-type')
});
}
});
// Element to attribute
conversion.for('upcast').elementToAttribute({
view: {
name: 'span',
classes: 'highlight'
},
model: {
key: 'highlight',
value: true
}
});
// Attribute to attribute
conversion.for('upcast').attributeToAttribute({
view: {
key: 'data-align',
value: /^(left|right|center)$/
},
model: {
key: 'alignment',
value: viewElement => viewElement.getAttribute('data-align')
}
});Downcast (Model → View)
// Element to element (editing view)
conversion.for('editingDowncast').elementToElement({
model: 'infoBox',
view: (modelElement, { writer }) => {
return writer.createContainerElement('div', {
class: 'info-box'
});
}
});
// Element to element (data view - for saving)
conversion.for('dataDowncast').elementToElement({
model: 'infoBox',
view: (modelElement, { writer }) => {
return writer.createContainerElement('div', {
class: 'info-box',
'data-type': modelElement.getAttribute('type')
});
}
});
// Attribute to element
conversion.for('downcast').attributeToElement({
model: 'highlight',
view: (modelAttributeValue, { writer }) => {
return writer.createAttributeElement('span', {
class: 'highlight'
});
}
});
// Attribute to attribute
conversion.for('downcast').attributeToAttribute({
model: 'alignment',
view: modelAttributeValue => ({
key: 'data-align',
value: modelAttributeValue
})
});
// Marker to element (for highlights, comments, etc.)
conversion.for('editingDowncast').markerToElement({
model: 'comment',
view: (markerData, { writer }) => {
return writer.createUIElement('span', { class: 'comment-marker' });
}
});Two-Way Conversion
// Simplified two-way conversion
conversion.elementToElement({
model: 'infoBox',
view: 'div',
converterPriority: 'high'
});
// With options
conversion.attributeToAttribute({
model: {
key: 'alignment',
values: ['left', 'right', 'center']
},
view: {
left: { key: 'class', value: 'align-left' },
right: { key: 'class', value: 'align-right' },
center: { key: 'class', value: 'align-center' }
}
});View Layer
View Element Types
// Container element - can contain other elements
const container = writer.createContainerElement('div', { class: 'wrapper' });
// Attribute element - wraps text (like <strong>, <a>)
const attribute = writer.createAttributeElement('span', { class: 'highlight' });
// Empty element - self-closing (like <br>, <img>)
const empty = writer.createEmptyElement('br');
// UI element - non-editable UI
const ui = writer.createUIElement('span', { class: 'placeholder' }, function(domDocument) {
const domElement = this.toDomElement(domDocument);
domElement.textContent = 'Click to edit';
return domElement;
});
// Editable element - nested editable area
const editable = writer.createEditableElement('div', { class: 'editable-area' });View Attributes
// Setting attributes
writer.setAttribute('class', 'my-class', viewElement);
writer.setAttribute('data-id', '123', viewElement);
// Adding/removing classes
writer.addClass('active', viewElement);
writer.removeClass('inactive', viewElement);
// Setting styles
writer.setStyle('color', 'red', viewElement);
writer.setStyle({
'background-color': 'yellow',
'font-weight': 'bold'
}, viewElement);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
.datasetproperty - NO
.classList(usehasClass()/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 attributeseditor.editing.view.getDomRoot()followed by DOM access- Anything inside
editor.ui.componentFactorycallbacks 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.
Command Pattern
Basic Command
import { Command } from '@ckeditor/ckeditor5-core';
export default class InsertInfoBoxCommand extends Command {
execute(options = {}) {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
model.change(writer => {
const infoBox = writer.createElement('infoBox', {
type: options.type || 'info'
});
// Insert at selection
model.insertObject(infoBox, selection, null, {
setSelection: 'on',
findOptimalPosition: 'auto'
});
});
}
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
// Check if command can be executed
const allowedIn = model.schema.findAllowedParent(
selection.getFirstPosition(),
'infoBox'
);
this.isEnabled = allowedIn !== null;
// Update command value based on selection
const selectedElement = selection.getSelectedElement();
if (selectedElement && selectedElement.is('element', 'infoBox')) {
this.value = selectedElement.getAttribute('type');
} else {
this.value = null;
}
}
}Command Registration
// In plugin init()
init() {
const editor = this.editor;
editor.commands.add('insertInfoBox', new InsertInfoBoxCommand(editor));
// Execute command
editor.execute('insertInfoBox', { type: 'warning' });
// Check command state
const command = editor.commands.get('insertInfoBox');
console.log(command.isEnabled);
console.log(command.value);
}Plugin Architecture
Plugin Dependencies
import { Plugin } from '@ckeditor/ckeditor5-core';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
export default class MyPlugin extends Plugin {
// Required plugins
static get requires() {
return [Widget, 'Paragraph']; // Can mix classes and names
}
// Plugin name for dependency resolution
static get pluginName() {
return 'MyPlugin';
}
// Lifecycle methods
init() {
// Called after all dependencies are initialized
}
afterInit() {
// Called after all plugins are initialized
}
destroy() {
// Cleanup
super.destroy();
}
}Plugin Communication
// Access other plugins
init() {
const imagePlugin = this.editor.plugins.get('ImageUpload');
// Listen to events
this.listenTo(imagePlugin, 'uploadComplete', (evt, data) => {
console.log('Image uploaded:', data.url);
});
}
// Fire events
this.fire('myEvent', { data: 'value' });
// Decorate methods
decorate('execute'); // Allows listening to method callsEvent System
Event Types
// Model events
editor.model.document.on('change:data', (evt, batch) => {
console.log('Document changed');
});
// Selection events
editor.model.document.selection.on('change:range', () => {
console.log('Selection changed');
});
// View events
editor.editing.view.document.on('keydown', (evt, data) => {
if (data.keyCode === 13) { // Enter key
console.log('Enter pressed');
}
});
// Focus events
editor.editing.view.document.on('focus', () => {
console.log('Editor focused');
});
// Clipboard events
editor.editing.view.document.on('clipboardInput', (evt, data) => {
console.log('Paste event');
});Event Priorities
// Priority levels (higher = executed first)
editor.model.document.on('change:data', callback, { priority: 'highest' }); // 100000
editor.model.document.on('change:data', callback, { priority: 'high' }); // 1000
editor.model.document.on('change:data', callback, { priority: 'normal' }); // 0
editor.model.document.on('change:data', callback, { priority: 'low' }); // -1000
editor.model.document.on('change:data', callback, { priority: 'lowest' }); // -100000
// Stop event propagation
editor.model.document.on('change:data', (evt) => {
evt.stop(); // Stop propagation
evt.return = 'custom value'; // Return value
});Widget System
Creating Widgets
import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget';
// In downcast converter
conversion.for('editingDowncast').elementToElement({
model: 'infoBox',
view: (modelElement, { writer }) => {
const container = writer.createContainerElement('div', {
class: 'info-box'
});
// Make it a widget (selectable, with toolbar)
return toWidget(container, writer, {
label: 'Info box widget',
hasSelectionHandle: true
});
}
});
// Nested editable
conversion.for('editingDowncast').elementToElement({
model: 'infoBoxContent',
view: (modelElement, { writer }) => {
const editable = writer.createEditableElement('div', {
class: 'info-box-content'
});
return toWidgetEditable(editable, writer);
}
});Widget Utils
import { isWidget, getSelectedWidgetModel } from '@ckeditor/ckeditor5-widget';
// Check if element is widget
if (isWidget(viewElement)) {
// Handle widget
}
// Get selected widget
const widget = getSelectedWidgetModel(selection);Data Pipeline
Getting/Setting Data
// Get editor data (HTML)
const htmlData = editor.getData();
// Set editor data
editor.setData('<p>New content</p>');
// Get model data (for debugging)
const modelData = editor.model.document.getRoot();
// Get view data
const viewData = editor.data.toView(modelData);Custom Data Processors
import { DataProcessor } from '@ckeditor/ckeditor5-engine';
class MarkdownDataProcessor {
toView(data) {
// Convert Markdown to view
return markdown.toHTML(data);
}
toData(viewFragment) {
// Convert view to Markdown
return html.toMarkdown(viewFragment);
}
}
// Register
editor.data.processor = new MarkdownDataProcessor();Best Practices
Performance
// Batch model changes
model.change(writer => {
// Multiple operations in single change block
const element1 = writer.createElement('paragraph');
const element2 = writer.createElement('paragraph');
writer.insert(element1, position1);
writer.insert(element2, position2);
});
// Use enqueueChange for deferred operations
model.enqueueChange({ isUndoable: false }, writer => {
// Changes that shouldn't be in undo stack
});Memory Management
// Clean up listeners in destroy()
destroy() {
this.stopListening(); // Remove all listeners
super.destroy();
}
// Use WeakMap for element references
const elementMap = new WeakMap();Debugging
// Enable debug mode
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
CKEditorInspector.attach(editor);
// Log model structure
console.log(editor.model.document.getRoot().toJSON());
// Log view structure
console.log(editor.editing.view.document.getRoot());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
// 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
// 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
// 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
// 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
// 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
// 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
// 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)
# 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)
# 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
// 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
# 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
// 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
// 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
<!-- 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
// 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
# 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
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
// 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
// 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';
}# 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.
// 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:
// 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:
// 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-dataXSS Prevention
Never use insertAdjacentHTML or innerHTML with interpolated values. This triggers CodeQL js/xss-through-dom alerts:
// 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). 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) replaces the informal hook previously used to tweak RTE config at runtime.
- RTE in EXT:form (Feature #108966, v14.2+) — CKE5 is now available inside Form Framework
richtextelements. 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 for each major between your source and target. The TYPO3 Core CKEditor 5 Integration chapter 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 olderBeforeRichtextConfigurationPreparedEventor customgetConfigurationoverrides - [ ] EXT:form integration tested if the preset is used in forms
CKEditor 5 Plugin Development for TYPO3
Plugin Architecture
Standard Plugin Structure
packages/my-plugin/
├── src/
│ ├── index.js # Main export
│ ├── myplugin.js # Plugin entry point
│ ├── mypluginediting.js # Schema, converters, commands
│ ├── mypluginui.js # UI components
│ ├── myplugincommand.js # Command implementation
│ └── ui/
│ ├── mypluginview.js
│ └── mypluginformview.js
├── theme/
│ └── myplugin.css
├── lang/
│ └── translations/
│ ├── en.json
│ └── de.json
└── package.jsonMain Plugin Class
// src/myplugin.js
import { Plugin } from '@ckeditor/ckeditor5-core';
import MyPluginEditing from './mypluginediting';
import MyPluginUI from './mypluginui';
import '../theme/myplugin.css';
export default class MyPlugin extends Plugin {
/**
* Required plugins - instantiated before this plugin
*/
static get requires() {
return [MyPluginEditing, MyPluginUI];
}
/**
* Plugin name for identification
*/
static get pluginName() {
return 'MyPlugin';
}
/**
* Plugin initialization
*/
init() {
console.log('MyPlugin initialized');
}
/**
* Called after all plugins are initialized
*/
afterInit() {
// Integration with other plugins
}
/**
* Cleanup on destroy
*/
destroy() {
super.destroy();
}
}Editing Plugin
Schema Definition
// src/mypluginediting.js
import { Plugin } from '@ckeditor/ckeditor5-core';
import { Widget, toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget';
import MyPluginCommand from './myplugincommand';
export default class MyPluginEditing extends Plugin {
static get requires() {
return [Widget];
}
static get pluginName() {
return 'MyPluginEditing';
}
init() {
this._defineSchema();
this._defineConverters();
this._defineCommands();
}
/**
* Define model schema
*/
_defineSchema() {
const schema = this.editor.model.schema;
// Block element (like a content box)
schema.register('myPluginBox', {
inheritAllFrom: '$blockObject',
allowAttributes: ['boxType', 'boxTitle']
});
// Nested editable content
schema.register('myPluginContent', {
isLimit: true,
allowIn: 'myPluginBox',
allowContentOf: '$root'
});
// Inline element
schema.register('myPluginInline', {
allowWhere: '$text',
isInline: true,
isObject: true,
allowAttributes: ['data-id']
});
// Text attribute (like bold, italic)
schema.extend('$text', {
allowAttributes: 'myPluginHighlight'
});
}
/**
* Define model-view converters
*/
_defineConverters() {
const conversion = this.editor.conversion;
// --- Block Element Converters ---
// Upcast: view -> model
// NOTE: `viewElement` is a CKE5 view element, NOT a DOM element.
// Use `getAttribute()` here — `viewElement.dataset` does not exist
// and SonarCloud's `javascript:S7761` is a false positive on these
// callsites. See `ckeditor5-architecture.md` ->
// "Pitfall: View Elements Are Not DOM Elements".
conversion.for('upcast').elementToElement({
view: {
name: 'div',
classes: 'my-plugin-box'
},
model: (viewElement, { writer }) => {
return writer.createElement('myPluginBox', {
boxType: viewElement.getAttribute('data-type') || 'default',
boxTitle: viewElement.getAttribute('data-title') || ''
});
}
});
// Data downcast: model -> data view (for saving)
conversion.for('dataDowncast').elementToElement({
model: 'myPluginBox',
view: (modelElement, { writer }) => {
return writer.createContainerElement('div', {
class: 'my-plugin-box',
'data-type': modelElement.getAttribute('boxType'),
'data-title': modelElement.getAttribute('boxTitle')
});
}
});
// Editing downcast: model -> editing view (for editor display)
conversion.for('editingDowncast').elementToElement({
model: 'myPluginBox',
view: (modelElement, { writer }) => {
const boxType = modelElement.getAttribute('boxType');
const boxTitle = modelElement.getAttribute('boxTitle');
const container = writer.createContainerElement('div', {
class: `my-plugin-box my-plugin-box--${boxType}`
});
// Add title element
if (boxTitle) {
const titleElement = writer.createContainerElement('div', {
class: 'my-plugin-box__title'
});
writer.insert(writer.createPositionAt(titleElement, 0),
writer.createText(boxTitle));
writer.insert(writer.createPositionAt(container, 0), titleElement);
}
return toWidget(container, writer, {
label: 'Content box widget',
hasSelectionHandle: true
});
}
});
// --- Nested Content Converters ---
conversion.for('upcast').elementToElement({
view: {
name: 'div',
classes: 'my-plugin-content'
},
model: 'myPluginContent'
});
conversion.for('dataDowncast').elementToElement({
model: 'myPluginContent',
view: {
name: 'div',
classes: 'my-plugin-content'
}
});
conversion.for('editingDowncast').elementToElement({
model: 'myPluginContent',
view: (modelElement, { writer }) => {
const content = writer.createEditableElement('div', {
class: 'my-plugin-content'
});
return toWidgetEditable(content, writer);
}
});
// --- Attribute Converters ---
conversion.for('downcast').attributeToAttribute({
model: {
name: 'myPluginBox',
key: 'boxType'
},
view: modelAttributeValue => ({
key: 'data-type',
value: modelAttributeValue
})
});
// --- Text Attribute Converters ---
conversion.for('upcast').elementToAttribute({
view: {
name: 'mark',
classes: 'my-highlight'
},
model: {
key: 'myPluginHighlight',
value: true
}
});
conversion.for('downcast').attributeToElement({
model: 'myPluginHighlight',
view: (modelAttributeValue, { writer }) => {
if (modelAttributeValue) {
return writer.createAttributeElement('mark', {
class: 'my-highlight'
});
}
}
});
}
/**
* Define commands
*/
_defineCommands() {
const editor = this.editor;
editor.commands.add('insertMyPluginBox', new MyPluginCommand(editor));
editor.commands.add('updateMyPluginBox', new UpdateMyPluginBoxCommand(editor));
editor.commands.add('toggleMyPluginHighlight', new ToggleHighlightCommand(editor));
}
}Command Implementation
Insert Command
// src/myplugincommand.js
import { Command } from '@ckeditor/ckeditor5-core';
export default class MyPluginCommand extends Command {
/**
* Execute the command
*/
execute(options = {}) {
const editor = this.editor;
const model = editor.model;
model.change(writer => {
// Create the box element
const myPluginBox = writer.createElement('myPluginBox', {
boxType: options.type || 'info',
boxTitle: options.title || ''
});
// Create nested content
const myPluginContent = writer.createElement('myPluginContent');
const paragraph = writer.createElement('paragraph');
writer.append(paragraph, myPluginContent);
writer.append(myPluginContent, myPluginBox);
// Insert into document
model.insertObject(myPluginBox, null, null, {
setSelection: 'on',
findOptimalPosition: 'auto'
});
});
}
/**
* Refresh command state
*/
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
// Check if we can insert at current position
const allowedIn = model.schema.findAllowedParent(
selection.getFirstPosition(),
'myPluginBox'
);
this.isEnabled = allowedIn !== null;
// Get current value if inside a box
const selectedElement = selection.getSelectedElement();
if (selectedElement && selectedElement.is('element', 'myPluginBox')) {
this.value = {
type: selectedElement.getAttribute('boxType'),
title: selectedElement.getAttribute('boxTitle')
};
} else {
this.value = null;
}
}
}Update Command
// src/updatemypluginboxcommand.js
import { Command } from '@ckeditor/ckeditor5-core';
export default class UpdateMyPluginBoxCommand extends Command {
execute(options) {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
const selectedElement = selection.getSelectedElement();
if (selectedElement && selectedElement.is('element', 'myPluginBox')) {
model.change(writer => {
if (options.type !== undefined) {
writer.setAttribute('boxType', options.type, selectedElement);
}
if (options.title !== undefined) {
writer.setAttribute('boxTitle', options.title, selectedElement);
}
});
}
}
refresh() {
const selection = this.editor.model.document.selection;
const selectedElement = selection.getSelectedElement();
this.isEnabled = selectedElement && selectedElement.is('element', 'myPluginBox');
}
}Toggle Attribute Command
// src/togglehighlightcommand.js
import { Command } from '@ckeditor/ckeditor5-core';
export default class ToggleHighlightCommand extends Command {
execute() {
const model = this.editor.model;
const selection = model.document.selection;
model.change(writer => {
const ranges = model.schema.getValidRanges(
selection.getRanges(),
'myPluginHighlight'
);
for (const range of ranges) {
if (this.value) {
writer.removeAttribute('myPluginHighlight', range);
} else {
writer.setAttribute('myPluginHighlight', true, range);
}
}
});
}
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
this.isEnabled = model.schema.checkAttributeInSelection(
selection,
'myPluginHighlight'
);
this.value = selection.hasAttribute('myPluginHighlight');
}
}UI Plugin
Button UI
// src/mypluginui.js
import { Plugin } from '@ckeditor/ckeditor5-core';
import { ButtonView } from '@ckeditor/ckeditor5-ui';
import boxIcon from '../theme/icons/box.svg';
export default class MyPluginUI extends Plugin {
static get pluginName() {
return 'MyPluginUI';
}
init() {
const editor = this.editor;
const t = editor.t;
// Add button to toolbar
editor.ui.componentFactory.add('myPluginBox', locale => {
const command = editor.commands.get('insertMyPluginBox');
const buttonView = new ButtonView(locale);
buttonView.set({
label: t('Insert Box'),
icon: boxIcon,
tooltip: true,
withText: false
});
// Bind button state to command
buttonView.bind('isEnabled').to(command);
buttonView.bind('isOn').to(command, 'value', value => !!value);
// Execute command on click
buttonView.on('execute', () => {
editor.execute('insertMyPluginBox', { type: 'info' });
editor.editing.view.focus();
});
return buttonView;
});
// Add highlight button
editor.ui.componentFactory.add('myPluginHighlight', locale => {
const command = editor.commands.get('toggleMyPluginHighlight');
const buttonView = new ButtonView(locale);
buttonView.set({
label: t('Highlight'),
icon: '<svg>...</svg>',
tooltip: true,
isToggleable: true
});
buttonView.bind('isEnabled').to(command);
buttonView.bind('isOn').to(command, 'value');
buttonView.on('execute', () => {
editor.execute('toggleMyPluginHighlight');
editor.editing.view.focus();
});
return buttonView;
});
}
}Dropdown UI
// src/mypluginui.js
import { Plugin } from '@ckeditor/ckeditor5-core';
import {
createDropdown,
addListToDropdown,
Model,
ViewModel
} from '@ckeditor/ckeditor5-ui';
import { Collection } from '@ckeditor/ckeditor5-utils';
export default class MyPluginUI extends Plugin {
init() {
const editor = this.editor;
const t = editor.t;
editor.ui.componentFactory.add('myPluginDropdown', locale => {
const dropdownView = createDropdown(locale);
const command = editor.commands.get('insertMyPluginBox');
// Create dropdown items
const items = new Collection();
const boxTypes = [
{ type: 'info', label: 'Info Box', icon: '💡' },
{ type: 'warning', label: 'Warning Box', icon: '⚠️' },
{ type: 'success', label: 'Success Box', icon: '✅' },
{ type: 'error', label: 'Error Box', icon: '❌' }
];
for (const boxType of boxTypes) {
const itemModel = new Model({
type: boxType.type,
label: `${boxType.icon} ${boxType.label}`,
withText: true
});
items.add({
type: 'button',
model: itemModel
});
}
addListToDropdown(dropdownView, items);
// Configure dropdown button
dropdownView.buttonView.set({
label: t('Insert Box'),
tooltip: true,
withText: true
});
dropdownView.bind('isEnabled').to(command);
// Handle item selection
dropdownView.on('execute', evt => {
editor.execute('insertMyPluginBox', {
type: evt.source.type
});
editor.editing.view.focus();
});
return dropdownView;
});
}
}Balloon/Contextual UI
// src/mypluginui.js
import { Plugin } from '@ckeditor/ckeditor5-core';
import { ContextualBalloon, clickOutsideHandler } from '@ckeditor/ckeditor5-ui';
import MyPluginFormView from './ui/mypluginformview';
export default class MyPluginUI extends Plugin {
static get requires() {
return [ContextualBalloon];
}
init() {
const editor = this.editor;
this._balloon = editor.plugins.get(ContextualBalloon);
this._formView = this._createFormView();
// Add toolbar button that opens balloon
editor.ui.componentFactory.add('myPluginEdit', () => {
const button = new ButtonView();
button.set({
label: 'Edit Box',
tooltip: true,
withText: true
});
button.on('execute', () => {
this._showUI();
});
return button;
});
}
_createFormView() {
const editor = this.editor;
const formView = new MyPluginFormView(editor.locale);
// Handle form submission
formView.on('submit', () => {
editor.execute('updateMyPluginBox', {
type: formView.typeInputView.fieldView.value,
title: formView.titleInputView.fieldView.value
});
this._hideUI();
});
// Handle cancel
formView.on('cancel', () => {
this._hideUI();
});
// Close on click outside
clickOutsideHandler({
emitter: formView,
activator: () => this._balloon.visibleView === formView,
contextElements: [this._balloon.view.element],
callback: () => this._hideUI()
});
return formView;
}
_showUI() {
const selection = this.editor.model.document.selection;
const selectedElement = selection.getSelectedElement();
if (selectedElement) {
// Populate form with current values
this._formView.typeInputView.fieldView.value =
selectedElement.getAttribute('boxType') || '';
this._formView.titleInputView.fieldView.value =
selectedElement.getAttribute('boxTitle') || '';
}
this._balloon.add({
view: this._formView,
position: this._getBalloonPositionData()
});
this._formView.focus();
}
_hideUI() {
this._balloon.remove(this._formView);
this.editor.editing.view.focus();
}
_getBalloonPositionData() {
const view = this.editor.editing.view;
const viewDocument = view.document;
return {
target: view.domConverter.mapViewToDom(
viewDocument.selection.getSelectedElement()
)
};
}
}Form View
// src/ui/mypluginformview.js
import {
View,
LabeledFieldView,
createLabeledInputText,
ButtonView,
submitHandler
} from '@ckeditor/ckeditor5-ui';
import { icons } from '@ckeditor/ckeditor5-core';
export default class MyPluginFormView extends View {
constructor(locale) {
super(locale);
const t = locale.t;
// Create form fields
this.typeInputView = this._createInput(t('Box Type'));
this.titleInputView = this._createInput(t('Title'));
// Create buttons
this.saveButtonView = this._createButton(
t('Save'),
icons.check,
'ck-button-save'
);
this.saveButtonView.type = 'submit';
this.cancelButtonView = this._createButton(
t('Cancel'),
icons.cancel,
'ck-button-cancel'
);
this.cancelButtonView.delegate('execute').to(this, 'cancel');
// Define template
this.setTemplate({
tag: 'form',
attributes: {
class: ['ck', 'ck-my-plugin-form'],
tabindex: '-1'
},
children: [
this.typeInputView,
this.titleInputView,
this.saveButtonView,
this.cancelButtonView
]
});
}
render() {
super.render();
// Submit handler
submitHandler({
view: this
});
}
focus() {
this.typeInputView.focus();
}
_createInput(label) {
const labeledInput = new LabeledFieldView(this.locale, createLabeledInputText);
labeledInput.label = label;
return labeledInput;
}
_createButton(label, icon, className) {
const button = new ButtonView(this.locale);
button.set({
label,
icon,
tooltip: true,
class: className
});
return button;
}
}TYPO3 Integration
Bundle for TYPO3
// 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 };CSS for TYPO3
/* Resources/Public/Css/Ckeditor/my-plugin.css */
/* Editor styles */
.ck-editor .my-plugin-box {
border: 2px solid #ddd;
border-radius: 4px;
padding: 1rem;
margin: 1rem 0;
}
.ck-editor .my-plugin-box--info {
border-color: #17a2b8;
background-color: #d1ecf1;
}
.ck-editor .my-plugin-box--warning {
border-color: #ffc107;
background-color: #fff3cd;
}
.ck-editor .my-plugin-box--success {
border-color: #28a745;
background-color: #d4edda;
}
.ck-editor .my-plugin-box--error {
border-color: #dc3545;
background-color: #f8d7da;
}
.ck-editor .my-plugin-box__title {
font-weight: bold;
margin-bottom: 0.5rem;
}
.ck-editor .my-plugin-content {
min-height: 2rem;
}
/* Widget selection handle */
.ck-editor .my-plugin-box.ck-widget {
outline: none;
}
.ck-editor .my-plugin-box.ck-widget.ck-widget_selected {
outline: 3px solid var(--ck-color-focus-border);
}
/* Form styles */
.ck-my-plugin-form {
padding: 1rem;
}
.ck-my-plugin-form .ck-labeled-field-view {
margin-bottom: 1rem;
}Registration in ext_localconf.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
# 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' }Testing
Unit Tests
// tests/myplugincommand.test.js
import { Editor } from '@ckeditor/ckeditor5-core';
import { Paragraph } from '@ckeditor/ckeditor5-paragraph';
import MyPluginEditing from '../src/mypluginediting';
import MyPluginCommand from '../src/myplugincommand';
describe('MyPluginCommand', () => {
let editor;
let command;
beforeEach(async () => {
editor = await Editor.create(document.createElement('div'), {
plugins: [Paragraph, MyPluginEditing]
});
command = editor.commands.get('insertMyPluginBox');
});
afterEach(async () => {
await editor.destroy();
});
it('should be disabled in empty editor', () => {
expect(command.isEnabled).toBe(true);
});
it('should insert box with default type', () => {
command.execute();
const root = editor.model.document.getRoot();
const box = root.getChild(0);
expect(box.name).toBe('myPluginBox');
expect(box.getAttribute('boxType')).toBe('info');
});
it('should insert box with specified type', () => {
command.execute({ type: 'warning', title: 'Test' });
const root = editor.model.document.getRoot();
const box = root.getChild(0);
expect(box.getAttribute('boxType')).toBe('warning');
expect(box.getAttribute('boxTitle')).toBe('Test');
});
});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:
<!-- 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:
// ❌ 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)
// 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:
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
// 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:
/**
* 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
// 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
// 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
// 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):
// 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
// 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()Best Practices
1. Separate Concerns: Keep editing (schema, converters) and UI separate 2. Use Commands: All model changes should go through commands 3. Proper Cleanup: Implement destroy() methods 4. Accessibility: Add proper labels and keyboard navigation 5. Performance: Use efficient converters, avoid unnecessary re-renders 6. Testing: Write unit tests for commands and converters 7. Documentation: Document public API and configuration options 8. Consumable API: Always test() before consume() in upcast converters 9. No jQuery: Use native DOM APIs exclusively -- see "Native DOM Patterns" above
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.phpYAML Configuration
# 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
- embedPHP Registration
Preset Registration
<?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
// 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
// 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
// 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
// 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
// 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
# 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 OutputCustom Processing
# 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: 0PHP Processing Hook
<?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.).
// 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" additionalParamsExample: 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
# 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
- telephoneFormEngine Link Browser (Not RTE-Specific)
When building link browser URLs for custom image dialogs, use FormEngine-style parameters instead of RTE-specific adapters:
// 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:
/**
* 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
// 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
# 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
# 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
// 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
// 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
#!/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