CKEditor 5 plugin development (Trilium monorepo)
CKEditor 5 is plugin-based: every feature — even typing and <p> 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:
- Core editor architecture (library
ckeditor5-core) — glue classes: Editor,
Plugin, Command, plus the event/observable system.
- Editing engine (library
ckeditor5-engine) — the custom MVC data model, the
view (virtual DOM), schema, and conversion between them. The biggest piece.
- 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:
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.)
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 ↔ <mark> element, a command, a button, a
keystroke. This is the canonical shape for inline styling features.
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 <mark>.
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
- 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").
- Register it so it reaches the editor (full flow in
references/tooling-and-packaging.md):
- For a new workspace package, add
"@triliumnext/ckeditor5-<feature>": "workspace:*" to
packages/ckeditor5/package.json.
- Import it in
packages/ckeditor5/src/plugins.ts and add it to the right array —
CORE_PLUGINS (minimal/attribute editor), TRILIUM_PLUGINS (in-repo src/plugins/), or
EXTERNAL_PLUGINS (the @triliumnext workspace packages). These compose into
COMMON_PLUGINS, which the editor classes in packages/ckeditor5/src/index.ts expose as
static builtinPlugins.
- Add the component name to the toolbar in
apps/client/src/widgets/type_widgets/text/toolbar.ts.
- Always reach for the CKEditor 5 Inspector while developing — it shows the live model,
view, schema, commands, and selection.
import CKEditorInspector from '@ckeditor/ckeditor5-inspector'; CKEditorInspector.attach( editor );
- Change the model, not the DOM. Wrap all model mutations in
editor.model.change( writer => … )
(one block = one undo step). Use editor.editing.view.change() only for view-only state
(e.g. focus class) that the model does not represent.
- Lint & test per package with pnpm workspace filters:
pnpm --filter @triliumnext/ckeditor5-<feature> test (also lint, stylelint, test:debug).
- Verify with
editor.getData() / editor.setData() and by exercising selection edge
cases (collapsed vs. ranged, inside objects/limits).
- A changed plugin won't apply to an already-open editor via HMR. A plugin's
init() runs
only when the editor is built, so do a full page reload (or close/reopen the note) to get a
fresh editor instance that picks up your change — otherwise you're testing the old code.
Reference map
Load the focused reference for the task at hand:
| File |
Use it for |
references/architecture.md |
Model, view, schema, positions/ranges/selections, markers, the event/observable system, binding. The conceptual foundation. |
references/conversion.md |
Upcast/downcast pipelines, conversion helpers, custom (callback) converters, attribute/element/marker conversion, position mapping. |
references/commands.md |
Command patterns: refresh()/execute(), state (value/isEnabled), forceDisabled(), affectsData, command events. |
references/ui-and-localization.md |
Views & templates, component catalog (buttons, inputs, dropdowns, dialogs/modals, balloons, toolbars), icons, componentFactory, focus/keystroke management, and t() localization. |
references/widgets.md |
Block & inline widgets: toWidget/toWidgetEditable, nested editables, insertObject, widget toolbars, view↔model position mapping, custom properties, and external/async-rendered widgets (UI-element render callbacks, re-render on change, stale-render guard, lazy-load). |
references/conventions.md |
Trilium conventions: imports from ckeditor5/@triliumnext + required file extensions, per-package license/headers (not uniform), @triliumnext scope + workspace:*, per-package tsconfig, ?raw icons, localization via editor.t() message ids, declare module 'ckeditor5' augmentation, plus the upstream naming/CSS/BEM/JSDoc/TypeScript rules inherited via eslint-config-ckeditor5. For writing idiomatic code and reviewing. |
references/tooling-and-packaging.md |
Trilium packaging & wiring: the @triliumnext/ckeditor5-<feature> package layout, workspace:* deps, main: src/index.ts (no per-package dist), tsconfig/eslint/stylelint setup, the full registration flow (plugins.ts arrays → editor classes builtinPlugins → toolbar.ts), the three editor classes, the Vite build, how apps/client creates the editor (config, watchdog, lazy premium), and the Inspector. |
references/persisted-attributes.md |
Persisting a data-trilium-* attribute end to end: schema → both conversion directions → the deliberate markdown export/import decision (collapsed is DROPPED, task-state is KEPT) → editing-view-only CSS so read-only and share rendering stay correct. Read it before storing plugin state in the saved note content. |
references/review-checklist.md |
A structured checklist for reviewing an existing plugin (architecture, schema, conversion, commands, UI, a11y, conventions). |
references/recipes.md |
Task-oriented how-tos: insert content, find/iterate nodes, custom observers, place caret, extend other plugins' UI, etc. |
references/core-plugin-patterns.md |
Canonical idioms mined from the actual packages/*/src source: toolbar+menu-bar button factory, plugin flags & augmentation.ts, AttributeCommand/setAttributeProperties, inline-attribute boundary helpers, elementToStructure+slots, reconversion, BalloonToolbar, raw-HTML widgets, sanitizing untrusted HTML (CKEditor ships no sanitizer — the host supplies one per feature namespace), clipboard pipeline, markers, post-fixers, async/upload. Each cites its source file. |
For testing a plugin (Vitest setup, test editors, model/view assertions, command/UI test
patterns), use the separate ckeditor5-testing skill.
Quick review checklist (summary)
When reviewing a plugin, confirm: editing/UI split with a glue plugin; static get requires()
and pluginName present; schema registered/extended and the feature self-configures; symmetric
upcast + (data & editing) downcast converters; a Command whose refresh() sets isEnabled
correctly (disabled where the schema disallows it); UI bound to command state and refocusing the
editing view on execute; keyboard accessibility (keystrokes + accessibility.addKeystrokeInfos);
all user-facing strings wrapped in t(); model changes inside model.change(); cleanup of
trackers/handlers in destroy(). Full version: references/review-checklist.md. To drive a
review (workflow, CKEditor-specific defect patterns, contribution process), use the separate
ckeditor5-reviewing skill, which delegates back to this checklist.
Scope & sources
This skill is specific to the Trilium (TriliumNext Notes) monorepo's CKEditor 5 integration.
Repository paths it cites — packages/ckeditor5, packages/ckeditor5/src/plugins/<name>/,
apps/client/... — are this repository, and examples come from
Trilium's own plugins (admonition, collapsible, footnotes, keyboard_marker, math, mermaid). The
CKEditor 5 library is an external dependency tracked at 48 or later; its mechanics were
distilled from the upstream docs (ckeditor.com/docs) and source (github.com/ckeditor/ckeditor5,
commit 9ecca53627). Where a snippet cites an upstream library package (e.g. ckeditor5-basic-styles,
-link, -image), that is the library's own source — not a Trilium package.
On versions: these skills name major versions only ("48 or later"). Trilium tracks CKEditor
5 closely, so an exact pin written here would be stale within weeks — read the current one from
packages/ckeditor5/package.json.
1---2name: ckeditor5-plugin-development3description: 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, persisting a `data-trilium-*` attribute through the model→view→data→markdown→share pipeline, or adding a new plugin folder under src/plugins/. Covers the architecture, idiomatic patterns, Trilium packaging/registration, code-style conventions, and a review checklist.4---56# CKEditor 5 plugin development (Trilium monorepo)78CKEditor 5 is **plugin-based**: every feature — even typing and `<p>` support — is a9plugin. Without plugins the editor is an empty API. This skill is specific to **Trilium10(TriliumNext Notes)**, whose rich-text note editor is built from the CKEditor 5 library11(external dep, **CKEditor 5 48 or later**) plus Trilium's own plugins. Both the editor build and the12plugins live in `packages/ckeditor5` (`@triliumnext/ckeditor5`): each feature is a folder under13`src/plugins/` — admonition, collapsible, footnotes, keyboard_marker, math, mermaid, mention,14snippets and the rest — with its tests co-located beside it. No CKEditor feature ships as its own15workspace package any more. The editor is consumed by `apps/client` (the text note widget). This skill distills16how to write new Trilium plugins and review existing ones idiomatically.1718## When to use this skill1920Use it whenever the task involves a Trilium CKEditor 5 plugin/feature: creating one (a folder under21`packages/ckeditor5/src/plugins/` — separate packages are not the pattern any more), extending one,22debugging editing behavior, registering a23plugin so it reaches the editor, or reviewing plugin code for correctness and convention24compliance. Trigger concepts include: model/view/schema, conversion (upcast/downcast),25`Command`, `editor.model.change()`, `ButtonView`/`componentFactory`, widgets (`toWidget`),26`ContextualBalloon`/`Dialog`, `editor.keystrokes`, `t()` localization, the `plugins.ts`27registry / editor classes / `toolbar.ts`.2829## The three pillars3031These are the library's internal layers (upstream packages `ckeditor5-core`/`-engine`/`-ui`);32in Trilium you never import them by those paths — everything comes from the `ckeditor5` aggregate33(see below). They describe how the engine is organized:34351. **Core editor architecture** (library `ckeditor5-core`) — glue classes: `Editor`,36 `Plugin`, `Command`, plus the event/observable system.372. **Editing engine** (library `ckeditor5-engine`) — the custom MVC data **model**, the38 **view** (virtual DOM), **schema**, and **conversion** between them. The biggest piece.393. **UI library** (library `ckeditor5-ui`) — MVC views, templates, and components40 (buttons, dropdowns, dialogs, toolbars).4142Mental model of the engine: there is **one model document** that is **converted** into two43views — the **editing view** (what the user sees/edits) and the **data view** (input/output44for `getData()`/`setData()`/paste). You almost always change the **model**; converters45render it to the view. Never hand-edit the view to represent model state.4647```48data (HTML) ──upcast──▶ MODEL ──editing downcast──▶ editing view ──render──▶ DOM (contentEditable)49 │50 └────data downcast──────▶ data view ──▶ getData()/output HTML51```5253## Importing CKEditor in Trilium5455Import everything from the single **`ckeditor5`** aggregate package (**48 or later**; it is a56`peerDependency` + `devDependency` of every plugin package). There is no premium package: every57premium plugin Trilium used has an in-tree GPL replacement, and the editor always runs under the58`GPL` license key:5960```ts61import { Plugin, ButtonView, Command, _setModelData } from 'ckeditor5';62```6364- **Cross-plugin** imports inside `packages/ckeditor5` are **relative**, e.g.65 `import Kbd from './keyboard_marker/keyboard_marker.js';`. The only workspace-package import66 in-tree under `src/plugins/`, and the aggregate registers them from `plugins.ts`.67- **Every import includes its file extension** (`.js`/`.ts`/`.json`) — enforced by68 `eslint-config-ckeditor5` (`require-file-extensions-in-imports`), with69 `allow-imports-only-from-main-package-entry-point` and `no-legacy-imports` also active.70- The `@ckeditor/ckeditor5-*` deep paths you'll see in the library's own source (and cited in71 `references/core-plugin-patterns.md`) resolve to the same symbols, but in Trilium you always72 import from the `ckeditor5` aggregate to avoid duplicate-module-instance problems. The only73 routine exceptions are dev/debug packages: `@ckeditor/ckeditor5-icons` and the74 **CKEditor Inspector** (`import CKEditorInspector from '@ckeditor/ckeditor5-inspector';`).7576## Plugin anatomy7778A plugin `extends Plugin` (from `'ckeditor5'`). There is **no** `isOfficialPlugin`/`isPremiumPlugin`79flag in Trilium plugins. (License headers are not uniform across packages — some, e.g. admonition,80prefix files with a CKSource header; others don't. Match the package you're in; see81`references/conventions.md`.)8283```ts84import { Plugin } from 'ckeditor5';85import FooEditing from './fooediting.js';86import FooUI from './fooui.js';8788export default class Foo extends Plugin {89 // Dependencies — the editor loads these automatically before this plugin.90 static get requires() {91 return [ FooEditing, FooUI ] as const;92 }9394 // Stable name (PascalCase = the package/folder) for editor.plugins.get( 'Foo' ).95 static get pluginName() {96 return 'Foo' as const;97 }9899 init() {100 const editor = this.editor; // the editor that loaded this plugin101 // Register schema, converters, commands, UI, keystrokes, listeners…102 }103104 afterInit() {105 // Runs after ALL plugins' init(). Use it when you depend on another106 // plugin's runtime state (e.g. registering a widget toolbar).107 }108109 // init()/afterInit() may return a Promise. Plugin extends a base that provides110 // destroy() and this.listenTo()/this.stopListening() (auto-cleaned on destroy).111}112```113114Plugin folder layout (e.g. `packages/ckeditor5/src/plugins/admonition/`), all files flat and115snake_case: `{feature}.ts` glue, `{feature}_editing.ts`, `{feature}_ui.ts`, optional116`{feature}_command.ts`. Put the `declare module 'ckeditor5'` augmentation at the **bottom of the117glue file**: every folded-in plugin dropped its separate `augmentation.ts` and `index.ts` barrel118that way, so consumers import the specific module they need. (`syntax_highlighting` still uses the119older separate-file shape — leave it, but don't copy it.) Complex plugins add `constants.ts`120(`ELEMENTS`/`ATTRIBUTES`/`COMMANDS`/`CLASSES`), `utils.ts` (model-query helpers), and split121`schema.ts`/`converters.ts`. Tests sit beside the source as `*.spec.ts`.122123Assets live in the package's shared folders, not per plugin: stylesheets in124`packages/ckeditor5/src/theme/{feature}.css` (imported from the glue plugin) and icons in125`packages/ckeditor5/src/icons/`, prefixed where the name would otherwise be generic126(`mermaid-info.svg`). A plugin derived from third-party code also keeps a `README.md` recording127its provenance, and a `LICENSE.md` where upstream requires one. See `references/conventions.md`.128129Key rules (inherited from the upstream conventions via `eslint-config-ckeditor5`):130131- Every feature is a plugin; plugins are **highly granular** and should know **as little132 about other plugins as possible** (communicate via commands, events, and the schema).133- **Split editing from UI.** The standard pattern is three plugins:134 - `Feature` — the **glue** plugin: `static get requires() { return [ FeatureEditing, FeatureUI ] as const; }`135 - `FeatureEditing` — schema, conversion, commands (works headless / server-side).136 - `FeatureUI` — buttons, dropdowns, balloons registered in `componentFactory`.137 This enables reuse (someone can take your editing layer and write a different UI). Simple138 text-attribute features can reuse the built-in `AttributeCommand` inline (see keyboard-marker).139- Register UI in `editor.ui.componentFactory.add( 'name', locale => view )`, then the component140 `'name'` is added to Trilium's toolbar config (`apps/client/.../text/toolbar.ts`).141- Make features self-configuring: pre-configure the schema and provide config defaults via142 `editor.config.define( 'feature', { … } )`, read with `editor.config.get( 'feature.key' )`.143- SVG icons are imported with `?raw` (`import fooIcon from '../theme/icons/foo.svg?raw';`) and144 surfaced through `export const icons = { fooIcon }` in `index.ts`.145146## Minimal end-to-end example (inline text attribute)147148A "highlight" feature = a `$text` attribute ↔ `<mark>` element, a command, a button, a149keystroke. This is the canonical shape for inline styling features.150151```js152import { Plugin, Command, ButtonView } from 'ckeditor5';153154class HighlightCommand extends Command {155 refresh() {156 const { document, schema } = this.editor.model;157 this.value = document.selection.getAttribute( 'highlight' );158 this.isEnabled = schema.checkAttributeInSelection( document.selection, 'highlight' );159 }160 execute() {161 const model = this.editor.model;162 const selection = model.document.selection;163 const newValue = !this.value;164 model.change( writer => {165 if ( !selection.isCollapsed ) {166 for ( const range of model.schema.getValidRanges( selection.getRanges(), 'highlight' ) ) {167 newValue ? writer.setAttribute( 'highlight', true, range )168 : writer.removeAttribute( 'highlight', range );169 }170 }171 newValue ? writer.setSelectionAttribute( 'highlight', true )172 : writer.removeSelectionAttribute( 'highlight' );173 } );174 }175}176177export default class Highlight extends Plugin {178 init() {179 const editor = this.editor;180181 // 1. Schema: allow the attribute on text.182 editor.model.schema.extend( '$text', { allowAttributes: 'highlight' } );183184 // 2. Conversion: model attribute 'highlight' <-> view <mark>.185 editor.conversion.attributeToElement( { model: 'highlight', view: 'mark' } );186187 // 3. Command.188 editor.commands.add( 'highlight', new HighlightCommand( editor ) );189190 // 4. UI button, reactive to command state.191 editor.ui.componentFactory.add( 'highlight', locale => {192 const button = new ButtonView( locale );193 const command = editor.commands.get( 'highlight' );194 button.set( { label: editor.t( 'Highlight' ), withText: true, isToggleable: true, tooltip: true } );195 button.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );196 button.on( 'execute', () => { editor.execute( 'highlight' ); editor.editing.view.focus(); } );197 return button;198 } );199200 // 5. Keystroke.201 editor.keystrokes.set( 'Ctrl+Alt+H', 'highlight' );202 }203}204```205206The same five steps (schema → conversion → command → UI → keystroke) recur in almost every207feature. For elements/objects/widgets you `schema.register(...)` and use `elementToElement`208converters instead of `attributeToElement`; see `references/widgets.md`.209210## Development workflow2112121. **Write the plugin.** A folder under `packages/ckeditor5/src/plugins/`. Separate workspace213 packages are no longer the pattern — every one that existed has been folded in, since none had214 consumers outside the aggregate or was ever published. See215 `references/tooling-and-packaging.md` ("Where a new plugin goes").2162. **Register it so it reaches the editor** (full flow in `references/tooling-and-packaging.md`):217 - For a new workspace package, add `"@triliumnext/ckeditor5-<feature>": "workspace:*"` to218 `packages/ckeditor5/package.json`.219 - Import it in `packages/ckeditor5/src/plugins.ts` and add it to the right array —220 `CORE_PLUGINS` (minimal/attribute editor), `TRILIUM_PLUGINS` (in-repo `src/plugins/`), or221 `EXTERNAL_PLUGINS` (the `@triliumnext` workspace packages). These compose into222 `COMMON_PLUGINS`, which the editor classes in `packages/ckeditor5/src/index.ts` expose as223 `static builtinPlugins`.224 - Add the component name to the toolbar in `apps/client/src/widgets/type_widgets/text/toolbar.ts`.225- **Always reach for the CKEditor 5 Inspector** while developing — it shows the live model,226 view, schema, commands, and selection. `import CKEditorInspector from '@ckeditor/ckeditor5-inspector'; CKEditorInspector.attach( editor );`227- **Change the model, not the DOM.** Wrap all model mutations in `editor.model.change( writer => … )`228 (one block = one undo step). Use `editor.editing.view.change()` only for view-only state229 (e.g. focus class) that the model does not represent.230- **Lint & test per package** with pnpm workspace filters:231 `pnpm --filter @triliumnext/ckeditor5-<feature> test` (also `lint`, `stylelint`, `test:debug`).232- **Verify** with `editor.getData()` / `editor.setData()` and by exercising selection edge233 cases (collapsed vs. ranged, inside objects/limits).234- **A changed plugin won't apply to an already-open editor via HMR.** A plugin's `init()` runs235 only when the editor is *built*, so do a **full page reload** (or close/reopen the note) to get a236 fresh editor instance that picks up your change — otherwise you're testing the old code.237238## Reference map239240Load the focused reference for the task at hand:241242| File | Use it for |243|------|-----------|244| `references/architecture.md` | Model, view, schema, positions/ranges/selections, markers, the event/observable system, binding. The conceptual foundation. |245| `references/conversion.md` | Upcast/downcast pipelines, conversion helpers, custom (callback) converters, attribute/element/marker conversion, position mapping. |246| `references/commands.md` | `Command` patterns: `refresh()`/`execute()`, state (`value`/`isEnabled`), `forceDisabled()`, `affectsData`, command events. |247| `references/ui-and-localization.md` | Views & templates, component catalog (buttons, inputs, dropdowns, dialogs/modals, balloons, toolbars), icons, `componentFactory`, focus/keystroke management, and `t()` localization. |248| `references/widgets.md` | Block & inline widgets: `toWidget`/`toWidgetEditable`, nested editables, `insertObject`, widget toolbars, view↔model position mapping, custom properties, and external/async-rendered widgets (UI-element render callbacks, re-render on change, stale-render guard, lazy-load). |249| `references/conventions.md` | Trilium conventions: imports from `ckeditor5`/`@triliumnext` + required file extensions, per-package license/headers (not uniform), `@triliumnext` scope + `workspace:*`, per-package tsconfig, `?raw` icons, localization via `editor.t()` message ids, `declare module 'ckeditor5'` augmentation, plus the upstream naming/CSS/BEM/JSDoc/TypeScript rules inherited via `eslint-config-ckeditor5`. For writing idiomatic code and reviewing. |250| `references/tooling-and-packaging.md` | Trilium packaging & wiring: the `@triliumnext/ckeditor5-<feature>` package layout, `workspace:*` deps, `main: src/index.ts` (no per-package dist), tsconfig/eslint/stylelint setup, the full registration flow (`plugins.ts` arrays → editor classes `builtinPlugins` → `toolbar.ts`), the three editor classes, the Vite build, how `apps/client` creates the editor (config, watchdog, lazy premium), and the Inspector. |251| `references/persisted-attributes.md` | Persisting a `data-trilium-*` attribute end to end: schema → both conversion directions → the **deliberate** markdown export/import decision (collapsed is DROPPED, task-state is KEPT) → editing-view-only CSS so read-only and share rendering stay correct. Read it before storing plugin state in the saved note content. |252| `references/review-checklist.md` | A structured checklist for reviewing an existing plugin (architecture, schema, conversion, commands, UI, a11y, conventions). |253| `references/recipes.md` | Task-oriented how-tos: insert content, find/iterate nodes, custom observers, place caret, extend other plugins' UI, etc. |254| `references/core-plugin-patterns.md` | Canonical idioms mined from the actual `packages/*/src` source: toolbar+menu-bar button factory, plugin flags & `augmentation.ts`, `AttributeCommand`/`setAttributeProperties`, inline-attribute boundary helpers, `elementToStructure`+slots, reconversion, `BalloonToolbar`, raw-HTML widgets, sanitizing untrusted HTML (CKEditor ships no sanitizer — the host supplies one per feature namespace), clipboard pipeline, markers, post-fixers, async/upload. Each cites its source file. |255256For **testing** a plugin (Vitest setup, test editors, model/view assertions, command/UI test257patterns), use the separate **`ckeditor5-testing`** skill.258259## Quick review checklist (summary)260261When reviewing a plugin, confirm: editing/UI split with a glue plugin; `static get requires()`262and `pluginName` present; schema registered/extended and the feature self-configures; symmetric263upcast + (data & editing) downcast converters; a `Command` whose `refresh()` sets `isEnabled`264correctly (disabled where the schema disallows it); UI bound to command state and refocusing the265editing view on execute; keyboard accessibility (keystrokes + `accessibility.addKeystrokeInfos`);266all user-facing strings wrapped in `t()`; model changes inside `model.change()`; cleanup of267trackers/handlers in `destroy()`. Full version: `references/review-checklist.md`. To **drive** a268review (workflow, CKEditor-specific defect patterns, contribution process), use the separate269**`ckeditor5-reviewing`** skill, which delegates back to this checklist.270271## Scope & sources272273This skill is specific to the **Trilium (TriliumNext Notes) monorepo's** CKEditor 5 integration.274Repository paths it cites — `packages/ckeditor5`, `packages/ckeditor5/src/plugins/<name>/`,275`apps/client/...` — are **this repository**, and examples come from276Trilium's own plugins (admonition, collapsible, footnotes, keyboard_marker, math, mermaid). The277CKEditor 5 **library** is an external dependency tracked at **48 or later**; its mechanics were278distilled from the upstream docs (ckeditor.com/docs) and source (github.com/ckeditor/ckeditor5,279commit 9ecca53627). Where a snippet cites an upstream library package (e.g. ckeditor5-basic-styles,280-link, -image), that is the library's own source — not a Trilium package.281282**On versions:** these skills name **major versions only** ("48 or later"). Trilium tracks CKEditor2835 closely, so an exact pin written here would be stale within weeks — read the current one from284`packages/ckeditor5/package.json`.