Last verified: 2026-08-18 · commit
86335cdSource:packages/draftly/src/editor/plugin.ts
The plugin contract is the single most important abstraction in Draftly. Everything a markdown feature needs — parsing, editing, styling, and static rendering — is declared on one class.
DraftlyPlugin (abstract)
├── DecorationPlugin (abstract) — decorationPriority defaults to 50,
│ buildDecorations() becomes abstract
└── SyntaxPlugin (abstract) — getMarkdownConfig() becomes abstract
The two subclasses add no behaviour. They exist purely to make intent explicit and to let
the compiler enforce that a decoration plugin actually decorates. Pick the one that
matches the plugin's centre of gravity; extend DraftlyPlugin directly when a plugin is
render-only (e.g. ParagraphPlugin).
abstract readonly name: string; // unique, kebab-case, e.g. "table"
abstract readonly version: string; // semver, bumped on breaking plugin changesname is used in generated CSS comments and by the playground's plugin toggles.
version is bumped independently of the package version — TablePlugin is at 2.0.0
after its rewrite while every other plugin is at 1.0.0.
readonly decorationPriority: number = 100; // ASCENDING — lower runs first
readonly dependencies: string[] = []; // declared but NOT yet enforced
readonly requiredNodes: readonly string[] = []; // preview dispatch keyrequiredNodes is load-bearing. PreviewRenderer builds a Map<nodeName, plugin[]>
from it; a plugin with renderToHTML() but no requiredNodes will never be called in
preview. This is the single most common way to write a plugin that "works in the editor
but not in preview".
dependencies is currently inert. It is declared on the base class but nothing reads
it — there is no topological sort and no validation. Treat it as documentation until the
ordering task lands (see ../tasks/ongoing/).
| Method | Returns | Called from |
|---|---|---|
getExtensions() |
Extension[] |
draftly() — merged into the bundle |
getMarkdownConfig() |
MarkdownConfig | null |
draftly() and PreviewRenderer |
getKeymap() |
KeyBinding[] |
draftly() — flattened into one keymap.of |
get theme() |
(t: ThemeEnum) => ThemeStyle |
draftly() → EditorView.theme; generateCSS() → scoped CSS |
getMarkdownConfig() being consumed by both surfaces is what guarantees the editor and
the preview parse identically. Never register a parser extension anywhere else.
buildDecorations(ctx: DecorationContext): voidCalled on every rebuild (doc change, selection change, viewport change). Push into
ctx.decorations; do not return, do not sort, do not mutate the view.
interface DecorationContext {
readonly view: EditorView;
readonly decorations: Range<Decoration>[];
selectionOverlapsRange(from: number, to: number): boolean;
cursorInRange(from: number, to: number): boolean;
}One plugin instance belongs to one editor. createEssentialPlugins()
(plugins/index.ts) and createAllPlugins() (plugins/all.ts) construct a fresh set on
every call, and a consumer calls one of them per editor.
This is not stylistic. Plugin objects carry per-editor state — _config and _context on
the base class, draftlyConfig and three pending-view re-entrancy locks on TablePlugin —
and before C-026 the exported essentialPlugins / allPlugins arrays were module-level
singletons shared by every importer. Two editors on one page therefore:
- overwrote each other's config. The second
draftly()call'sonRegisterreplaced the first's_context, so editor A rendered using editor B's configuration. - cancelled each other's scheduled work.
scheduleNormalizationguards on a singlependingNormalizationViewfield; B's schedule overwrote A's, and A's queued microtask then saw a mismatch and returned silently.
Those arrays were removed in C-028; the factories are the only way to build a plugin set.
The authoring rule that follows: a plugin must not hold state belonging to a view.
Anything derived from a specific EditorView — a pending timer, a scheduled microtask's
target, a cached measurement, the view itself — keys off the view (a WeakMap, or a
CodeMirror StateField) or is released in onViewDestroy. _config/_context are the
sanctioned exception, and only because they are written once at composition time.
Note that per-editor instances fix the sharing half of the problem, not the retention
half: an instance that holds a destroyed view still pins it. onViewDestroy remains
mandatory.
| Hook | When | Base behaviour |
|---|---|---|
onRegister(ctx) |
Composition time, before extensions | Stores _context |
onViewReady(view) |
ViewPlugin constructor |
No-op |
onViewUpdate(update) |
Every ViewUpdate, unconditionally |
No-op |
onViewDestroy(view) |
ViewPlugin.destroy() |
No-op |
onUnregister() |
Never — deprecated | Clears _context |
Release view-scoped state in onViewDestroy. A plugin instance outlives the view that
used it, so a retained EditorView retains its DOM, its state and the whole document for
the lifetime of the page. Added in C-018, along with the view plugin's destroy() — before
that the library had no teardown path at all. It fires on every reconfigure as well as on a
real teardown, because a host that rebuilds its extension array destroys and recreates the
view.
EditorView has no public "destroyed" flag, so async work already in flight cannot
ask the view whether it is still alive. TablePlugin keeps a WeakSet of torn-down views
and checks it before dispatching; copy that pattern rather than inventing another.
onUnregister is deprecated and never called. C-026 removed one of the two reasons —
with per-editor instances, clearing _context no longer breaks other editors — but the
other stands: plugin registration is not scoped to a view, so there is no event to fire it
on. It is kept because it is public API.
Always call super.onRegister(context) when overriding it — otherwise this.context
stays null and anything reading plugin config breaks. TablePlugin.onRegister is the
reference implementation.
onViewUpdate fires on every update, including ones that do not rebuild decorations.
It is where stateful plugins schedule deferred work — TablePlugin uses it to queue
markdown normalisation, cell padding, and selection repair outside the update cycle
(dispatching during an update is illegal in CodeMirror).
renderToHTML ? (node, children, ctx) : string | null | Promise<string | null>;Optional. Contract:
- Return an HTML string to take over rendering for this node.
- Return
nullto decline. The next candidate plugin for the node is tried, then the default renderer, then the escaped leaf fallback. This is how a plugin claims a node type broadly viarequiredNodesand then opts out per node — and it is the mechanism that lets a consumer's plugin sit alongside a built-in rather than replacing it. - Return
""to render the node as nothing (how syntax markers likeHeaderMarkare dropped from static output). childrenis pre-rendered HTML for the node's children — but note it is computed before the plugin is consulted, so declining still paid the cost.- May be
async; the whole preview pipeline is promise-based.
getPreviewStyles(theme, wrapperClass): stringHas a working default: runs this.theme(theme) through transformToCss(), which uses
StyleModule with a finish hook that prefixes every selector with .${wrapperClass}.
Override only when the preview markup structure differs enough from the editor DOM that
a mechanical prefix is wrong.
Canonical structure — follow heading-plugin.ts (197 LOC, exercises most of the contract):
/**
* Node names this plugin owns, hoisted so the decoration path and the
* preview path cannot drift apart.
*/
const HEADING_TYPES = ["ATXHeading1" /* … */] as const;
/** Decoration instances are module-level singletons — created once, reused. */
const headingMarkDecorations = {
"heading-1": Decoration.mark({ class: "cm-draftly-h1" }),
"heading-mark": Decoration.replace({}),
};
export class HeadingPlugin extends DecorationPlugin {
readonly name = "heading";
readonly version = "1.0.0";
override decorationPriority = 10;
override readonly requiredNodes = [...HEADING_TYPES, "HeaderMark"] as const;
override get theme() {
return theme;
}
buildDecorations(ctx: DecorationContext): void {
/* tree.iterate → push */
}
override renderToHTML(node, children): string | null {
/* reuse the same class names */
}
}
/** Theme lives at the bottom of the file, built with createTheme(). */
const theme = createTheme({
default: {
/* … */
},
dark: {
/* … */
},
});-
Walk the tree with
ctx.iterateVisible, neversyntaxTree(view.state).iterate. The context supplies the viewport bounds. An unbounded walk makes every update cost O(document), and decorations rebuild on cursor movement as well as edits — this was the library's dominant performance cost until C-016. Nodes overlapping the viewport are still entered, so constructs straddling the edge decorate correctly. -
Hoist decorations to module scope.
Decoration.mark({...})insidebuildDecorationsallocates on every keystroke. -
Read class names from the decoration specs in
renderToHTML.HeadingPlugindoesheadingMarkDecorations["heading-1"].spec.classrather than retyping the string — this is what mechanically enforces editor/preview parity. -
Guard every hiding decoration with
ctx.selectionOverlapsRange(from, to). -
Clamp
Decoration.replaceranges to the line end. Spanning a newline throws. -
Never dispatch from
buildDecorations. Schedule viaonViewUpdate+requestAnimationFrame/microtask, asTablePlugindoes. -
Keep the theme at the bottom of the file as a
createTheme()call. Split into*-plugin.theme.tswhen it dominates the file (precedent:code-plugin.theme.ts). -
Register in
plugins/index.ts— both the named export and thecreateEssentialPlugins()factory. A plugin with a heavy third-party dependency instead gets its own entry point (src/plugins/<name>.ts+tsup.config.ts+exports) and is added tocreateAllPlugins()inplugins/all.ts; putting it in the barrel puts its dependency in every consumer's bundle. Seebuild-and-tooling.md. -
Escape attribute values; sanitize fragments. These are different operations and
ctx.sanitize()only does the second one. An attribute value or a run of text goes throughescapeHtmlfromdraftly/lib; a blob of HTML that is meant to stay markup goes throughctx.sanitize(). Conflating them is what produced C-011 —href="${ctx.sanitize(url)}"let a quote in the URL open a new attribute, because DOMPurify parses fragments and a bare string is not one. -
WidgetType.eq()compares content, neverfrom/to. Positions shift on any edit above the widget, so comparing them meanseqnever reports equality and CodeMirror rebuilds the DOM on every keystroke — re-running KaTeX, mermaid and image loads. A handler that needs a range callsresolveWidgetRange(view, dom, [nodeName])fromdraftly/lib, which reads it from the live DOM at event time. -
A widget that starts async work implements
destroy(). Set adisposedflag, clear pending timers, and check bothdisposedandelement.isConnectedbefore writing DOM — the flag catches a teardown CodeMirror announced,isConnectedcatches an element that left the document without it.MermaidBlockWidgetandCodeBlockHeaderWidgetare the reference implementations. -
Decide whether your widget is decorative or a control, and build accordingly. A widget is a control only if activating it is the only way to do something —
TaskCheckboxWidgetis the sole example, because it mutates the document and the raw[ ]it replaces is hidden. Everything else merely reveals markdown the cursor can already reach, and CodeMirror's own accessibility model exposes that text.Decorative widgets need an accessible name (
role="img"plusaria-label, or an equivalent) so their content is announced. They must not be made focusable: focusable children insidecontenteditablefight the editor's focus and selection handling, for no gain when the underlying text is reachable anyway.A control's interaction belongs on
getKeymap(), which works regardless of focus semantics — that is howMod-Entertoggles a task.Do not add an
aria-labelover content that already carries its own accessible representation. KaTeX emits MathML beside its visual output; labelling the container would replace it with a flat string. -
Run every URL through
safeUrl()fromdraftly/libbefore it reaches anhref, asrc, orwindow.open— on both surfaces. Setting a DOM property protects against injection but not againstjavascript:. Pass{ allowDataImages: true }only for an imagesrc.
One number, two surfaces, and the sorts point opposite ways on purpose:
| Surface | Sort | Composition | Who wins |
|---|---|---|---|
| Editor | ascending | every plugin runs; later layers over earlier | higher priority |
| Preview | descending | first non-null renderToHTML result is used |
higher priority |
The outcome is the same on both — a higher number wins — which is the point. Preview used to dispatch in whatever order the consumer wrote their plugin array, so a custom plugin overriding a built-in behaved one way in the editor and another in preview. Fixed in C-014.
Two plugins claiming the same node at the same priority is genuinely ambiguous; it warns in development.
Current allocation:
| Range | Used by | Rationale |
|---|---|---|
| 10 | heading, quote, hr |
Block-level line decorations |
| 20 | inline, list, table, emoji |
Block structure and inline marks |
| 22–25 | link, code, image, math, mermaid |
Replacements and widgets |
| 30 | html |
Must observe everything else first |
| 50 | DecorationPlugin default |
|
| 100 | DraftlyPlugin default |
Pick a value inside an existing band rather than inventing a new one; note the choice in a JSDoc comment when it is non-obvious.
Adding a hook to DraftlyPlugin affects every plugin, so:
- Give it a working default on the base class — never make it abstract.
- Document it with JSDoc on the base class, including when it fires.
- Call it from exactly one place (
draftly.tsorview-plugin.tsorrenderer.ts). - Update this document and the JSDoc together.
- If it changes plugin-authoring expectations, bump the affected plugins'
version.