--- name: ckeditor5-plugin-development description: >- Write, extend, and review CKEditor 5 plugins in the Trilium (TriliumNext Notes) monorepo — the rich-text-note editor under packages/ckeditor5, whose plugins live in src/plugins/. Use when building or reviewing a Trilium CKEditor 5 feature/plugin, or when working with the editing engine (model, view, schema, conversion/upcast-downcast), commands, the UI library (buttons, dropdowns, dialogs, balloons, toolbars), widgets (block/inline, toWidget, nested editables), keystrokes, localization (t()), registering a plugin into plugins.ts / the editor classes / toolbar.ts, or adding a new plugin folder under src/plugins/. Covers the architecture, idiomatic patterns, Trilium packaging/registration, code-style conventions, and a review checklist. --- # CKEditor 5 plugin development (Trilium monorepo) CKEditor 5 is **plugin-based**: every feature — even typing and `
` support — is a
plugin. Without plugins the editor is an empty API. This skill is specific to **Trilium
(TriliumNext Notes)**, whose rich-text note editor is built from the CKEditor 5 library
(external dep, **CKEditor 5 48 or later**) plus Trilium's own plugins. Both the editor build and the
plugins live in `packages/ckeditor5` (`@triliumnext/ckeditor5`): each feature is a folder under
`src/plugins/` — admonition, collapsible, footnotes, keyboard_marker, math, mermaid, mention,
snippets and the rest — with its tests co-located beside it. No CKEditor feature ships as its own
workspace package any more. The editor is consumed by `apps/client` (the text note widget). This skill distills
how to write new Trilium plugins and review existing ones idiomatically.
## When to use this skill
Use it whenever the task involves a Trilium CKEditor 5 plugin/feature: creating one (a folder under
`packages/ckeditor5/src/plugins/` — separate packages are not the pattern any more), extending one,
debugging editing behavior, registering a
plugin so it reaches the editor, or reviewing plugin code for correctness and convention
compliance. Trigger concepts include: model/view/schema, conversion (upcast/downcast),
`Command`, `editor.model.change()`, `ButtonView`/`componentFactory`, widgets (`toWidget`),
`ContextualBalloon`/`Dialog`, `editor.keystrokes`, `t()` localization, the `plugins.ts`
registry / editor classes / `toolbar.ts`.
## The three pillars
These are the library's internal layers (upstream packages `ckeditor5-core`/`-engine`/`-ui`);
in Trilium you never import them by those paths — everything comes from the `ckeditor5` aggregate
(see below). They describe how the engine is organized:
1. **Core editor architecture** (library `ckeditor5-core`) — glue classes: `Editor`,
`Plugin`, `Command`, plus the event/observable system.
2. **Editing engine** (library `ckeditor5-engine`) — the custom MVC data **model**, the
**view** (virtual DOM), **schema**, and **conversion** between them. The biggest piece.
3. **UI library** (library `ckeditor5-ui`) — MVC views, templates, and components
(buttons, dropdowns, dialogs, toolbars).
Mental model of the engine: there is **one model document** that is **converted** into two
views — the **editing view** (what the user sees/edits) and the **data view** (input/output
for `getData()`/`setData()`/paste). You almost always change the **model**; converters
render it to the view. Never hand-edit the view to represent model state.
```
data (HTML) ──upcast──▶ MODEL ──editing downcast──▶ editing view ──render──▶ DOM (contentEditable)
│
└────data downcast──────▶ data view ──▶ getData()/output HTML
```
## Importing CKEditor in Trilium
Import everything from the single **`ckeditor5`** aggregate package (**48 or later**; it is a
`peerDependency` + `devDependency` of every plugin package). There is no premium package: every
premium plugin Trilium used has an in-tree GPL replacement, and the editor always runs under the
`GPL` license key:
```ts
import { Plugin, ButtonView, Command, _setModelData } from 'ckeditor5';
```
- **Cross-plugin** imports inside `packages/ckeditor5` are **relative**, e.g.
`import Kbd from './keyboard_marker/keyboard_marker.js';`. The only workspace-package import
in-tree under `src/plugins/`, and the aggregate registers them from `plugins.ts`.
- **Every import includes its file extension** (`.js`/`.ts`/`.json`) — enforced by
`eslint-config-ckeditor5` (`require-file-extensions-in-imports`), with
`allow-imports-only-from-main-package-entry-point` and `no-legacy-imports` also active.
- The `@ckeditor/ckeditor5-*` deep paths you'll see in the library's own source (and cited in
`references/core-plugin-patterns.md`) resolve to the same symbols, but in Trilium you always
import from the `ckeditor5` aggregate to avoid duplicate-module-instance problems. The only
routine exceptions are dev/debug packages: `@ckeditor/ckeditor5-icons` and the
**CKEditor Inspector** (`import CKEditorInspector from '@ckeditor/ckeditor5-inspector';`).
## Plugin anatomy
A plugin `extends Plugin` (from `'ckeditor5'`). There is **no** `isOfficialPlugin`/`isPremiumPlugin`
flag in Trilium plugins. (License headers are not uniform across packages — some, e.g. admonition,
prefix files with a CKSource header; others don't. Match the package you're in; see
`references/conventions.md`.)
```ts
import { Plugin } from 'ckeditor5';
import FooEditing from './fooediting.js';
import FooUI from './fooui.js';
export default class Foo extends Plugin {
// Dependencies — the editor loads these automatically before this plugin.
static get requires() {
return [ FooEditing, FooUI ] as const;
}
// Stable name (PascalCase = the package/folder) for editor.plugins.get( 'Foo' ).
static get pluginName() {
return 'Foo' as const;
}
init() {
const editor = this.editor; // the editor that loaded this plugin
// Register schema, converters, commands, UI, keystrokes, listeners…
}
afterInit() {
// Runs after ALL plugins' init(). Use it when you depend on another
// plugin's runtime state (e.g. registering a widget toolbar).
}
// init()/afterInit() may return a Promise. Plugin extends a base that provides
// destroy() and this.listenTo()/this.stopListening() (auto-cleaned on destroy).
}
```
Plugin folder layout (e.g. `packages/ckeditor5/src/plugins/admonition/`), all files flat and
snake_case: `{feature}.ts` glue, `{feature}_editing.ts`, `{feature}_ui.ts`, optional
`{feature}_command.ts`. Put the `declare module 'ckeditor5'` augmentation at the **bottom of the
glue file**: every folded-in plugin dropped its separate `augmentation.ts` and `index.ts` barrel
that way, so consumers import the specific module they need. (`syntax_highlighting` still uses the
older separate-file shape — leave it, but don't copy it.) Complex plugins add `constants.ts`
(`ELEMENTS`/`ATTRIBUTES`/`COMMANDS`/`CLASSES`), `utils.ts` (model-query helpers), and split
`schema.ts`/`converters.ts`. Tests sit beside the source as `*.spec.ts`.
Assets live in the package's shared folders, not per plugin: stylesheets in
`packages/ckeditor5/src/theme/{feature}.css` (imported from the glue plugin) and icons in
`packages/ckeditor5/src/icons/`, prefixed where the name would otherwise be generic
(`mermaid-info.svg`). A plugin derived from third-party code also keeps a `README.md` recording
its provenance, and a `LICENSE.md` where upstream requires one. See `references/conventions.md`.
Key rules (inherited from the upstream conventions via `eslint-config-ckeditor5`):
- Every feature is a plugin; plugins are **highly granular** and should know **as little
about other plugins as possible** (communicate via commands, events, and the schema).
- **Split editing from UI.** The standard pattern is three plugins:
- `Feature` — the **glue** plugin: `static get requires() { return [ FeatureEditing, FeatureUI ] as const; }`
- `FeatureEditing` — schema, conversion, commands (works headless / server-side).
- `FeatureUI` — buttons, dropdowns, balloons registered in `componentFactory`.
This enables reuse (someone can take your editing layer and write a different UI). Simple
text-attribute features can reuse the built-in `AttributeCommand` inline (see keyboard-marker).
- Register UI in `editor.ui.componentFactory.add( 'name', locale => view )`, then the component
`'name'` is added to Trilium's toolbar config (`apps/client/.../text/toolbar.ts`).
- Make features self-configuring: pre-configure the schema and provide config defaults via
`editor.config.define( 'feature', { … } )`, read with `editor.config.get( 'feature.key' )`.
- SVG icons are imported with `?raw` (`import fooIcon from '../theme/icons/foo.svg?raw';`) and
surfaced through `export const icons = { fooIcon }` in `index.ts`.
## Minimal end-to-end example (inline text attribute)
A "highlight" feature = a `$text` attribute ↔ `` element, a command, a button, a
keystroke. This is the canonical shape for inline styling features.
```js
import { Plugin, Command, ButtonView } from 'ckeditor5';
class HighlightCommand extends Command {
refresh() {
const { document, schema } = this.editor.model;
this.value = document.selection.getAttribute( 'highlight' );
this.isEnabled = schema.checkAttributeInSelection( document.selection, 'highlight' );
}
execute() {
const model = this.editor.model;
const selection = model.document.selection;
const newValue = !this.value;
model.change( writer => {
if ( !selection.isCollapsed ) {
for ( const range of model.schema.getValidRanges( selection.getRanges(), 'highlight' ) ) {
newValue ? writer.setAttribute( 'highlight', true, range )
: writer.removeAttribute( 'highlight', range );
}
}
newValue ? writer.setSelectionAttribute( 'highlight', true )
: writer.removeSelectionAttribute( 'highlight' );
} );
}
}
export default class Highlight extends Plugin {
init() {
const editor = this.editor;
// 1. Schema: allow the attribute on text.
editor.model.schema.extend( '$text', { allowAttributes: 'highlight' } );
// 2. Conversion: model attribute 'highlight' <-> view .
editor.conversion.attributeToElement( { model: 'highlight', view: 'mark' } );
// 3. Command.
editor.commands.add( 'highlight', new HighlightCommand( editor ) );
// 4. UI button, reactive to command state.
editor.ui.componentFactory.add( 'highlight', locale => {
const button = new ButtonView( locale );
const command = editor.commands.get( 'highlight' );
button.set( { label: editor.t( 'Highlight' ), withText: true, isToggleable: true, tooltip: true } );
button.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
button.on( 'execute', () => { editor.execute( 'highlight' ); editor.editing.view.focus(); } );
return button;
} );
// 5. Keystroke.
editor.keystrokes.set( 'Ctrl+Alt+H', 'highlight' );
}
}
```
The same five steps (schema → conversion → command → UI → keystroke) recur in almost every
feature. For elements/objects/widgets you `schema.register(...)` and use `elementToElement`
converters instead of `attributeToElement`; see `references/widgets.md`.
## Development workflow
1. **Write the plugin.** A folder under `packages/ckeditor5/src/plugins/`. Separate workspace
packages are no longer the pattern — every one that existed has been folded in, since none had
consumers outside the aggregate or was ever published. See
`references/tooling-and-packaging.md` ("Where a new plugin goes").
2. **Register it so it reaches the editor** (full flow in `references/tooling-and-packaging.md`):
- For a new workspace package, add `"@triliumnext/ckeditor5-