# edodo-write — complete documentation (v0.9.3)
> A Notion/Medium-style WYSIWYG editor whose single source of truth is Markdown. Type-to-format, a slash menu, a floating selection toolbar — framework-agnostic core with an optional React wrapper.
- Live site (rendered docs + playground): https://vivmagarwal.github.io/edodo-write/
- npm package: https://www.npmjs.com/package/edodo-write (`npm i edodo-write`)
- Source repo: https://github.com/vivmagarwal/edodo-write
This ONE file concatenates every guide, generated verbatim from the source repo.
---
# Getting started
# Getting started
`edodo-write` is a Notion / Medium-style WYSIWYG editor whose **single source of
truth is Markdown**. You edit rich text; you read and store Markdown.
## Install
```bash
npm i edodo-write
```
`react` / `react-dom` are optional peers — you only need them for the React
wrapper. The core is framework-free (3 runtime dependencies: `marked`,
`turndown`, and the turndown GFM plugin).
The package ships five entry points:
| Import | Contents |
|---|---|
| `edodo-write` | `EdodoWrite` core + functional helpers (framework-free) |
| `edodo-write/react` | `` + `` |
| `edodo-write/plugins` | First-party plugins: `highlight()`, `callout()`, `math()`, `diagrams()` / `edodoDraw()`, `tags()`, `embeds()` + the widget helpers |
| `edodo-write/testing` | `createCodec` / `assertRoundTrip` for plugin authors |
| `edodo-write/styles.css` | The stylesheet — import it explicitly |
## Vanilla (no framework)
```ts
import { EdodoWrite } from "edodo-write";
import "edodo-write/styles.css";
import { strict as assert } from "node:assert";
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, {
value: "# Hello\n\nType **markdown** and watch it render.",
placeholder: "Write something…",
onChange: (markdown) => console.log(markdown),
});
// Markdown is the value — read or replace it at any time.
assert.equal(editor.getMarkdown(), "# Hello\n\nType **markdown** and watch it render.");
editor.setMarkdown("# A new document");
assert.equal(editor.getMarkdown(), "# A new document");
editor.destroy();
```
## React
```tsx
import { useState } from "react";
import { EdodoWriteEditor, Markdown } from "edodo-write/react";
import "edodo-write/styles.css";
export function Notes() {
const [md, setMd] = useState("# Hello\n\nStart writing…");
return (
{/* read-only render of stored Markdown, sharing the editor's stylesheet */}
);
}
```
## Plugins in one minute
Optional features ship as plugins; pass them at construction. Six are
first-party: `highlight()` (`==text==` ↔ ``, with a `Mod-Shift-H`
shortcut and a toolbar button), `callout()` (Notion-style callouts stored as
GitHub alert syntax, `> [!NOTE]`), `math()` (`$x^2$` / `$$…$$` TeX — KaTeX
when installed), `diagrams()` / `edodoDraw()` (fenced ` ```edd ` and
` ```mermaid ` blocks rendered as live diagrams), `tags({ source })`
(`#tag`/`@mention` chips fed by your own suggestion source), and `embeds()`
(a bare URL line becomes a video / audio / bookmark widget). Every one stores
plain, degradable Markdown — the full guide is
**[First-party plugins](FIRST_PARTY_PLUGINS.md)**.
```ts
import { EdodoWrite } from "edodo-write";
import { highlight, callout } from "edodo-write/plugins";
import { strict as assert } from "node:assert";
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, {
value: "Some ==highlighted== words.",
plugins: [highlight(), callout()],
exclude: ["taskList"], // remove core features you don't want
});
// The plugin's markdown extension round-trips ==…== byte-for-byte.
assert.equal(editor.getMarkdown(), "Some ==highlighted== words.");
// Excluded features are gone: exec refuses and returns false.
assert.equal(editor.exec("taskList"), false);
editor.destroy();
```
## Static HTML / CDN (no build step)
Two supported routes for plain HTML pages:
**Self-contained bundle** (no import map, no third-party rewriting — one
stylesheet, one module; ~52 KB gzipped with all first-party plugins):
```html
```
Also on jsDelivr: `https://cdn.jsdelivr.net/npm/edodo-write/dist-lib/standalone.js`.
Bundlers get the same entry as `import … from "edodo-write/standalone"`.
**esm.sh** (resolves the regular entries and their dependencies on the fly —
including the optional `katex`/`edododraw` engines for math and diagrams):
```html
```
In the standalone bundle the optional engines stay external by design:
`math()` falls back to plain TeX and `edodoDraw()` shows a readable error
unless `katex`/`edododraw` are reachable (add an import map, or use esm.sh).
## What you get out of the box
- **Type-to-format** — `# ` … `###### `, `- `, `1. `, `[ ] `, `> `, `` ``` ``,
`---`, and inline `**bold**`, `*italic*`, `` `code` ``, `~~strike~~`.
- **Slash menu** — `/` on an empty line (or list item) opens a grouped, filterable
block picker; multi-word queries like `/heading 1` work.
- **Floating toolbar** on text selection (Medium-style).
- **Link popover** — ⌘/Ctrl+K, the toolbar button, or clicking an existing link
opens an inline edit/open/remove popover (no `window.prompt`).
- **Block handles** — hover a block for a `+` insert button and a `⣿` grip:
drag to reorder, click for a block menu (Turn into, Duplicate, Copy as
Markdown, Delete).
- **Markdown clipboard** — copy puts Markdown on the clipboard; paste accepts
Markdown *and* rich HTML (converted to Markdown, then rendered as blocks).
- **Undo/redo** — a Markdown-snapshot history (⌘/Ctrl+Z, ⌘/Ctrl+Shift+Z or
⌘/Ctrl+Y), consistent across every operation.
- **Interactive task lists** — tick a checkbox and the Markdown flips
`[ ]` → `[x]`.
- **Tables** — `/table` inserts a GFM table; type in cells, Tab/Enter walk
them (Tab at the end adds a row), and hovering a cell reveals Notion-style
column/row handles that insert, move, clear, and
columns — see [Tables](MARKDOWN_AND_SHORTCUTS.md#tables).
- **Images** — paste a screenshot, drag-and-drop files, or use `/image`
(Upload button or a URL + alt form); hosting is pluggable via `uploadImage`,
with a zero-config `data:`-URL fallback, and the value is always just
`` — see [Image hosting](IMAGE_HOSTING.md).
- **A plugin API** — commands, input rules, keymaps, slash/toolbar/block-menu
items, and paired markdown extensions.
- **Light & dark** themes via CSS variables.
## Where next
- **[Embed in your app (API)](INTEGRATION_GUIDE.md)** — every option, method,
event and command; React contract; styling.
- **[Markdown support & shortcuts](MARKDOWN_AND_SHORTCUTS.md)** — everything you
can type, the full keyboard table, and the serialised-Markdown flavour.
- **[Image hosting](IMAGE_HOSTING.md)** — where image bytes go: the
`uploadImage` contract, worked hosting configs, the data-URL fallback.
- **[First-party plugins](FIRST_PARTY_PLUGINS.md)** — highlight, callout,
math, diagrams, tags, embeds: options, the exact Markdown each stores, the
degradation story.
- **[Plugin guide](PLUGIN_GUIDE.md)** — write your own plugin.
- **[Architecture](ARCHITECTURE.md)** — how the Markdown round-trip works.
---
# Architecture
# Architecture
edodo-write is a thin controller over a single native `contentEditable`
surface. The guiding model — the **façade over Markdown** — is: the surface is
the *view*, **Markdown is the state**, and parse/serialize is the *reconciler*.
There is no bespoke document model to learn, migrate, or store; the bytes you
persist are Markdown, and everything else exists to keep that contract honest.
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite } from "edodo-write";
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, { value: "# Title\n\nHello **world**" });
// The view is HTML; the state is Markdown.
assert.ok(editor.getHTML().includes("
Title
"));
assert.equal(editor.getMarkdown(), "# Title\n\nHello **world**");
editor.destroy();
```
## Pipeline
```
Markdown ──parse (marked + plugin extensions + sanitize + task decoration)──▶ HTML view
▲ │
└────────── serialize (turndown + gfm + plugin rules + fence-aware tidy) ◀────┘
(on every edit)
```
- **Parse** (`src/core/parse.ts`) — a per-editor `new Marked({ gfm: true })`
with any plugin `marked` extensions applied, then `sanitizeHtml`, then
task-list decoration (interactive checkboxes + the conventional
`task-list-item` classes). `{ sanitize: false }` returns raw marked HTML for
trusted, DOM-free SSR; `{ decorateTasks: false }` keeps GFM's disabled
checkboxes for export paths (the clipboard's HTML flavour).
- **Serialize** (`src/core/serialize.ts`) — a per-editor `TurndownService`
(ATX headings, `-` bullets, fenced code, `*`/`**` delimiters, inlined links)
plus the GFM plugin (tables, strikethrough, task lists) and plugin rules.
Notable choices: hard breaks serialize as **backslash breaks** (turndown's
default two-space break is invisible and destroyed by whitespace trims);
`<` and entity-forming `&` are escaped so prose like `ac` survives a
round-trip; empty paragraphs are dropped. The output then passes through a
**fence-aware tidy**: NBSP → space, one-space list markers, trailing-space
trim, blank-line collapsing, ZWSP stripping — none of which ever touches the
inside of a code fence (pasted code keeps its bytes).
- **Sanitize** (`src/core/sanitize.ts`) — a dependency-free allow-list
scrubber. Unknown tags are unwrapped (children kept); scripts, iframes,
event handlers and script-scheme URLs are removed; only checkbox ``s
survive; `target="_blank"` links get `rel="noopener noreferrer"`. Plugins may
*widen* the allow-list, never lower the denial floor (see below).
## Per-instance pipeline (why the singletons had to die)
Earlier versions called the global `marked` singleton and one module-level
`TurndownService`. That breaks in three ways once plugins exist:
1. `marked.use(extension)` mutates **global** state — a plugin's tokenizer
would leak into every other editor on the page and into any other consumer
of marked in the application.
2. Two editors with different plugin sets need two different codecs.
3. The clipboard must encode/decode with the **same** codec the editor renders
with, or plugin content silently corrupts on the way through copy/paste.
So each `EdodoWrite` now builds its own pipeline at construction:
`createMarkdownParser(markedExtensions, sanitizeOptions)` (a fresh `new
Marked()`), `createMarkdownSerializer(turndownExtensions)` (a fresh
`TurndownService`), and that pipeline object is threaded through the clipboard
handlers and exposed to plugins as `ctx.markdown`. The module-level
`parseMarkdown` / `htmlToMarkdown` remain, bound to default instances, for
standalone conversion. `createCodec` from `edodo-write/testing` builds the
exact codec an editor with a given plugin set would use, so tests and SSR
previews can match it byte-for-byte.
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite } from "edodo-write";
import { highlight } from "edodo-write/plugins";
const a = new EdodoWrite(document.createElement("div"), {
value: "some ==marked== text",
plugins: [highlight()],
});
const b = new EdodoWrite(document.createElement("div"), {
value: "some ==marked== text",
});
// Same page, same input, two different codecs — neither leaks into the other.
assert.ok(a.getHTML().includes("marked"));
assert.ok(!b.getHTML().includes(""));
assert.equal(a.getMarkdown(), "some ==marked== text"); // and it round-trips
a.destroy();
b.destroy();
```
## Modules
| File | Responsibility |
|---|---|
| `src/core/editor.ts` | `EdodoWrite` — mounts the surface, resolves plugins, owns events, undo history, the select-all reset, and all wiring. The only orchestrator. |
| `src/core/types.ts` | The public type surface: `EdodoPlugin`, `EditorContext`, `CommandPayloads` (module-augmentable), options, events. |
| `src/core/plugin.ts` | Plugin **resolution**: registries, collision detection (throws), key-string parsing, priority ordering, and `guard` (runtime error isolation). |
| `src/core/preset.ts` | The **core preset** — every built-in feature expressed through the same plugin API third parties use. |
| `src/core/commands.ts` | Built-in command implementations (manual-DOM block transforms, inline marks, link/image/divider). |
| `src/core/input-rules.ts` | The type-to-format **runner** — owns the contentEditable gotchas so rule authors never see them. Rule *sets* live in preset/plugins. |
| `src/core/keymap.ts` | Two tiers: registered bindings (pluggable) and the structural engine — Enter/Backspace/Tab splits and merges, undo routing, Mod-U swallow. |
| `src/core/normalize.ts` | The **document normalizer** — re-establishes the schema after every mutation (see below). |
| `src/core/clipboard.ts` | Copy/cut → Markdown + regenerated HTML; paste (Markdown, rich HTML, bare URL over selection) → real blocks. Pipeline-threaded. |
| `src/core/parse.ts` | Markdown → sanitised HTML (per-instance `Marked`). |
| `src/core/serialize.ts` | HTML → Markdown (per-instance `TurndownService` + fence-aware tidy). |
| `src/core/sanitize.ts` | Allow-list HTML sanitiser with a non-negotiable denial floor. |
| `src/core/slash-menu.ts` | The `/` picker: grouped items, word-wise multi-word filtering, works in empty list items. |
| `src/core/toolbar.ts` | Floating selection toolbar (items from the registry). |
| `src/core/block-handles.ts` | Left-gutter `+` / grip; pointer-based drag-to-reorder; grip *click* opens the block menu. |
| `src/core/ui.ts` | Editor-owned floating-UI primitives (`popover` / `menu` / `notify`) with selection preservation, clamping, dismissal, teardown. |
| `src/core/link-ui.ts` | The link popover (Mod-K, toolbar, click-a-link) built on `ui.ts`. |
| `src/core/dom.ts` | Stateless selection/caret/DOM helpers — the shared toolbox, exposed to plugins as `ctx.dom`. |
| `src/lib/index.ts` | Public core entry → `edodo-write`. |
| `src/lib/react.tsx` | React wrapper → `edodo-write/react` (core never imports React). |
| `src/lib/testing.ts` | `createCodec` / `assertRoundTrip` → `edodo-write/testing`. |
| `src/plugins/highlight.ts` | First-party plugin: `==text==` ↔ `` → `edodo-write/plugins`. |
| `src/plugins/callout.ts` | First-party plugin: GitHub alert callouts ↔ `
`. |
| `src/plugins/index.ts` | Plugin barrel (one module per plugin so bundlers tree-shake). |
| `src/styles.css` | All editor/toolbar/slash/popover/drag styles, themed via CSS variables. |
## The plugin registry (engine vs. features)
Everything above the engine is a **feature** and flows through `EdodoPlugin`:
commands, input rules, keybindings, slash items, toolbar buttons, block-menu
items, paired markdown extensions, sanitizer widening, `setup`, lifecycle
hooks. The built-ins are no exception — `corePreset()` in `preset.ts` registers
them through the exact same API (deliberate dogfooding: the registry code path
runs on every keystroke, so it cannot bit-rot). `options.exclude` removes core
preset keys.
The **engine** is deliberately *not* pluggable: structural Enter/Backspace/Tab
semantics, the undo history, the clipboard contract, the sanitizer's denial
floor, drag mechanics, and the document normalizer. These implement the
contentEditable invariants whose violation corrupts documents. Plugins can
*pre-empt* engine keys (a registered binding for `Enter` runs first) but never
remove them.
**Resolution** happens once, at construction: `resolvePlugins([corePreset(),
...options.plugins])` flattens everything into per-instance registries. There
is no runtime (un)registration — dynamic plugin churn is where stale-menu and
half-torn-down-rule bugs live; re-create the editor to change the set (the
React wrapper captures `plugins` on mount for the same reason).
**Failure philosophy**, two-sided:
- *Configuration mistakes throw at construction*, naming both offenders:
duplicate plugin names, duplicate command names, duplicate slash/toolbar/
block-menu item ids, malformed key strings. Never silent last-wins.
- *Runtime mistakes are isolated*: every plugin contribution — command bodies,
rule callbacks, key handlers, menu actions, `isActive` probes, lifecycle
hooks — runs inside `guard()` (a try/catch). A throwing plugin logs and is
skipped for that event; one bad plugin must not kill typing.
**Ordering**: the core preset registers at priority 0; plugins default to 100.
Keybindings are sorted by priority (descending), then registration order — so
a plugin can shadow `Mod-B`, and the structural key engine still runs last.
Input rules run in registration order (core preset first, then plugins in
array order).
**Markdown extensions are paired** (`markdown: { marked, turndown }`): a parse
extension without its serialize twin is a round-trip bug by construction. The
formats are marked's and turndown's own — deliberately unwrapped. Prove
stability with `assertRoundTrip` from `edodo-write/testing`.
## The document normalizer
`contentEditable` happily leaves the document in states the editor cannot work
with:
- **Select-all corruption** — select-all + Delete keeps the first block's
emptied shell, so the next keystroke lands inside a stale `
`.
- **Styled-span merges** — a native cross-block delete splices
`` runs into the surviving block.
- **Unplaceable carets** — a cut can leave a block with no caret anchor, after
which typing goes into the *previous* block.
- **Schema drift** — native edits drop bare text nodes or `
`s at the
root, after which input rules and the slash menu (which assume "root
children are blocks") silently die.
Instead of patching each symptom at its call site, `normalizeDocument`
(`src/core/normalize.ts`) re-establishes the schema invariants **after every
mutation** — the `input` handler runs it *before* matching input rules, and
`afterMutation` runs it after commands, paste, cut, drag, and block-menu
actions:
1. Root children are block elements only — stray inline/text runs are wrapped
into `
`, `
`s become paragraphs (or are unwrapped when they contain
blocks).
2. Browser styling artifacts are removed (`span[style]`/`font` unwrapped,
stray `style` attributes dropped).
3. Structural shells are repaired: lists with no `
` are removed, `
`
always wraps a ``, task items keep checkbox-first + a caret anchor.
4. Every empty block gets a placeable caret (` `, or a zero-width text node
inside `
` where a ` ` would mean a newline).
5. A childless root gets its single empty paragraph back.
The pass is cheap (one walk of the top-level children plus two targeted
queries) and idempotent. It deliberately does **not** reset "empty-looking"
documents — a freshly inserted empty heading is a legitimate state; the
select-all replace/delete reset lives in the editor's `beforeinput` handler,
where intent is known.
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite } from "edodo-write";
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, { value: "" });
// Simulate what a native edit can leave behind: a styled span spliced into
// a heading by a cross-block delete, and a bare
from a native Enter.
editor.content.innerHTML =
'
merged
typed
';
editor.content.dispatchEvent(new Event("input", { bubbles: true }));
// The normalizer re-established the schema before anything else ran.
assert.equal(editor.getHTML(), "
merged
typed
");
assert.equal(editor.getMarkdown(), "# merged\n\ntyped");
editor.destroy();
```
## Undo / redo
A stack of **Markdown snapshots** (`{ md, caret }` in `editor.ts`, capped at
300). Because Markdown is the state, undo literally restores previous state:
`setMarkdown` re-hydrates the view and the caret is re-placed by plain-text
offset (zero-width spaces excluded). Snapshots are recorded on every
structural change and — debounced (~120 ms) — on typing pauses, so a burst of
typing is one undo step. `transact()` batches any number of DOM mutations into
one snapshot and one change event, and is re-entrant (nested transactions
commit once, at the outermost level). This history is uniform across *all*
operations — typing, formatting, paste, drag, plugin commands — unlike native
`execCommand` undo, which cannot see manual DOM changes.
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite } from "edodo-write";
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, { value: "# One" });
editor.setMarkdown("# One\n\ntwo"); // records a history snapshot
editor.undo();
assert.equal(editor.getMarkdown(), "# One");
editor.redo();
assert.equal(editor.getMarkdown(), "# One\n\ntwo");
editor.destroy();
```
**The honest limitation:** history dedupes on the serialized Markdown, so two
view states that serialize identically collapse into one undo step. Anything
Markdown cannot express — a caret move, a selection, transient DOM the
serializer strips — is invisible to history, and the caret restore after undo
is a best-effort text offset, not an exact DOM position. This is the price of
snapshot-as-Markdown, accepted deliberately: the alternative (a DOM-diff or
operation log) would reintroduce exactly the competing document model this
project exists to avoid.
## Key contentEditable decisions
The full invariants catalog (with the bugs each rule prevents) is in
[DEVELOPMENT.md](DEVELOPMENT.md); the load-bearing decisions are:
- **Block transforms are hand-rolled DOM, never `execCommand`.** Chrome
silently drops `execCommand` block ops (`formatBlock`,
`insertUnorderedList`, …) called synchronously inside an `input` event —
exactly where input rules run. Inline marks (bold/italic/strike) still use
`execCommand` from the toolbar/keymap, which run outside `input`.
- **Enter/Backspace/Tab are intercepted** and performed as manual splits and
merges so the block model stays clean — real block tags (`
`, `
`,
`
`, `
`), never the stray `
` the browser inserts.
- **Empty blocks get a ` ` caret anchor** (`ensureNotEmpty`); inside
`
` the anchor is a zero-width text node instead, because a ` `
there would mean a newline.
- **Zero-width spaces park the caret** outside a freshly inserted inline mark
and after task checkboxes; they are stripped on serialize and never reach
the Markdown.
- **A typed trailing space arrives as `U+00A0`** — normalized before input
rules match and mapped back to a plain space by the serializer tidy (never
inside code fences).
- **One `contentEditable`, Markdown state** (the Medium model) rather than
Notion's per-block editors over a JSON block tree — simpler and
Markdown-native, at the cost of doing block splits/merges ourselves.
## Build
Vite, dual output:
- `vite.lib.config.ts` → `dist-lib/` (what npm ships): four ESM entries —
`index`, `react`, `plugins`, `testing` — mapping to the package exports
`"."`, `"./react"`, `"./plugins"`, `"./testing"`, plus one stylesheet
(`"./styles.css"` → `edodo-write.css`, imported explicitly by consumers).
All dependencies are externalised; types come from `tsc -p tsconfig.lib.json`.
- `vite.config.ts` → `dist/` — the playground/docs SPA for GitHub Pages.
`prebuild` regenerates `public/llms*.txt` from `docs/` via
`scripts/gen-llms-txt.mjs`.
Testing is three-staged (Vitest/jsdom, Playwright, executable doc examples) —
see [DEVELOPMENT.md](DEVELOPMENT.md). The plugin API itself is documented in
[PLUGIN_GUIDE.md](PLUGIN_GUIDE.md).
---
# Plugin guide
# Plugin guide
Everything above the engine is a plugin. Commands, type-to-format input rules,
keyboard shortcuts, slash-menu items, toolbar buttons, block-menu items,
Markdown syntax — the built-ins ship through the exact same API you are about
to use (`src/core/preset.ts` is the core preset expressed as one big plugin,
and living documentation of every extension point). This guide teaches that
API end to end: the mental model, a first plugin, block plugins, the
`EditorContext` toolbox, plugin UI, and the round-trip contract that keeps
Markdown honest.
Prerequisites: [Architecture](ARCHITECTURE.md) explains the façade-over-Markdown
model and the plugin registry's place in it; the
[Integration guide](INTEGRATION_GUIDE.md) covers the host-app API
(`EdodoWrite`, options, events). This document assumes both and does not repeat
them. Every `ts`/`js` code block below is executed by the test suite
(`tests/docs-examples.test.ts`), so what you read is what runs.
## The mental model
A plugin is a **declarative bag of contributions** — a frozen plain object,
created with `definePlugin({ name, … })` and passed to the constructor:
| Field | What it contributes |
|---|---|
| `name` | Unique kebab-case identifier (an optional `:suffix` is allowed). |
| `priority` | Ordering weight for input rules and keybindings. Core preset: `0`; plugins default to `100` — higher runs earlier. |
| `commands` | Named `CommandSpec`s (`run` + optional `isActive`). |
| `inputRules` | Block ("type `# ` at the start") and inline ("close the `==`") rules. |
| `keymap` | `"Mod-Shift-h"` → command name or handler function. |
| `slashItems` / `toolbarItems` / `blockMenuItems` | Entries for the `/` menu, the floating toolbar, and the block-handle menu. |
| `markdown` | **Paired** marked + turndown extensions (see [the round-trip contract](#the-round-trip-contract)). |
| `sanitize` | Additive allow-list widening so your parsed HTML survives. |
| `setup` | Imperative escape hatch, runs once after mount; may return a cleanup. |
| `on` | Lifecycle hooks: `change`, `selection`, `focus`, `blur`, `destroy`. |
Three rules govern how the bag is consumed:
1. **Registries are resolved once, at construction.**
`new EdodoWrite(host, { plugins })` flattens `[corePreset(), ...plugins]`
into per-instance registries and a per-instance Markdown pipeline. There is
no runtime (un)registration — dynamic plugin churn is where stale-menu and
half-torn-down-rule bugs live. To change the set, create a new editor (the
React wrapper captures `plugins` on mount for the same reason).
2. **Configuration mistakes throw; runtime mistakes are isolated.**
Duplicate plugin names, duplicate command names, duplicate slash/toolbar/
block-menu item ids, and malformed key strings **throw at construction,
naming both offenders** — never silent last-wins. At runtime the polarity
flips: every contribution (command bodies, rule callbacks, key handlers,
menu actions, `isActive` probes, lifecycle hooks) runs inside a try/catch
(`guard`); a throwing plugin logs and is skipped for that event. One bad
plugin must not kill typing.
3. **The engine is not pluggable.** Structural Enter/Backspace/Tab semantics,
the undo history, the clipboard contract, the sanitizer's denial floor, the
document normalizer, and drag mechanics implement the contentEditable
invariants whose violation corrupts documents. Plugins can *intercept*
engine keys — a registered binding for `"Enter"` runs before the engine,
and plugin keybindings (priority 100) run before the core preset (priority
0), so you can even shadow `Mod-b` — but the engine always runs last and
can never be removed.
Both failure modes, demonstrated:
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite, definePlugin } from "edodo-write";
// A minimal but complete plugin: one command.
const stamp = definePlugin({
name: "stamp",
commands: {
stamp: {
run: (ctx) => {
const p = document.createElement("p");
p.textContent = "stamped";
ctx.root.appendChild(p);
},
},
},
});
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, { value: "hello", plugins: [stamp] });
assert.equal(editor.exec("stamp"), true);
assert.equal(editor.getMarkdown(), "hello\n\nstamped");
editor.destroy();
// Configuration mistakes throw at construction, naming both offenders.
const rival = definePlugin({
name: "rival",
commands: { stamp: { run: () => {} } },
});
const host2 = document.createElement("div");
document.body.appendChild(host2);
assert.throws(
() => new EdodoWrite(host2, { plugins: [stamp, rival] }),
/command "stamp" registered by both "stamp" and "rival"/,
);
// Same for duplicate plugin names — and definePlugin validates upfront.
assert.throws(
() => new EdodoWrite(host2, { plugins: [stamp, stamp] }),
/duplicate plugin name "stamp"/,
);
assert.throws(() => definePlugin({ name: "Bad Name!" }), /kebab-case/);
assert.throws(
() => definePlugin({ name: "ok", keymap: { "Mod-Fnord-x": "bold" } }),
/unknown modifier/,
);
```
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite, definePlugin } from "edodo-write";
// Runtime mistakes are isolated: a throwing command logs and is skipped —
// the editor (and typing) survives.
const faulty = definePlugin({
name: "faulty",
commands: { boom: { run: () => { throw new Error("kaboom"); } } },
});
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, { value: "hello", plugins: [faulty] });
const errors: unknown[] = [];
const origError = console.error;
console.error = (...args: unknown[]) => { errors.push(args); };
editor.exec("boom");
console.error = origError;
assert.ok(errors.length > 0); // logged, not thrown
assert.equal(editor.getMarkdown(), "hello"); // document untouched
assert.equal(editor.exec("paragraph"), true); // editor still works
editor.destroy();
```
From React, pass plugins to the wrapper — they are captured on mount, so
remount (e.g. with a `key`) to change the set:
```tsx
import { EdodoWriteEditor } from "edodo-write/react";
import { highlight, callout } from "edodo-write/plugins";
export function Notes(props: { value: string; onChange: (md: string) => void }) {
return (
);
}
```
## Your first plugin: highlight
`==text==` ↔ `` — the canonical example, shipped as
`src/plugins/highlight.ts` and importable from `edodo-write/plugins`. It
exercises every non-UI extension point in ~50 lines. Here it is in full, built
from scratch (only the plugin `name` differs from the shipped one):
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite, definePlugin } from "edodo-write";
// See "Typed commands" for the CommandPayloads augmentation that makes
// editor.exec("highlight") a typed call in your own project.
const myHighlight = definePlugin({
name: "my-highlight",
// 1. The command: toggle at the selection. ctx.dom.toggleInlineTag
// is the generalized inline-wrap machinery (the same code path as inline
// code) — no execCommand, no hand-rolled ranges.
commands: {
highlight: {
run: (ctx) => ctx.dom.toggleInlineTag("mark"),
isActive: (ctx) => ctx.dom.isInlineTagActive("mark"),
},
},
// 2. The input rule: typing the closing "==" wraps the inner text.
// Inline triggers must be $-anchored; match[1] is the wrapped text.
inputRules: [
{ kind: "inline", trigger: /==([^=\n]+)==$/, apply: "mark" },
],
// 3. The keybinding: plugin bindings (priority 100) run before core (0).
keymap: {
"Mod-Shift-h": "highlight",
},
// 4. The toolbar button: highlight state defaults to the command's isActive.
toolbarItems: [
{ id: "highlight", label: "H", title: "Highlight (⌘⇧H)", command: "highlight" },
],
// 5. The PAIRED markdown extension: the marked tokenizer that READS ==…==
// ships in the same object as the turndown rule that WRITES it back.
markdown: {
marked: [{
extensions: [{
name: "highlight",
level: "inline",
start: (src: string) => src.indexOf("=="),
tokenizer(src: string) {
const m = /^==([^=\n]+)==/.exec(src);
if (!m) return undefined;
return {
type: "highlight",
raw: m[0],
text: m[1],
tokens: this.lexer.inlineTokens(m[1]),
};
},
renderer(token) {
return `${this.parser.parseInline(token.tokens ?? [])}`;
},
}],
}],
turndown: (td) => {
td.addRule("highlight", {
filter: "mark",
replacement: (content) => `==${content}==`,
});
},
},
// is already in the sanitizer allow-list; a tag that isn't would
// need: sanitize: { tags: ["mark"] }
});
// Loading stored markdown renders the mark; serialising writes it back —
// byte for byte.
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, {
value: "some ==highlighted== words",
plugins: [myHighlight],
});
assert.ok(editor.getHTML().includes("highlighted"));
assert.equal(editor.getMarkdown(), "some ==highlighted== words");
editor.destroy();
```
### Watch the input rule fire (and what the runner does for you)
Input rules run on the `input` event. The runner (`src/core/input-rules.ts`)
owns the contentEditable gotchas so your rule never sees them — for an inline
rule that means: after wrapping `match[1]` in the new element, the caret is
parked **after a zero-width space (ZWSP) outside the mark**. Without it,
Chrome keeps typing *inside* the fresh `` forever. The ZWSP is editor
furniture: it is stripped by the serializer (and the clipboard flavours), so
it never reaches your Markdown.
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite } from "edodo-write";
import { highlight } from "edodo-write/plugins";
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, { value: "placeholder", plugins: [highlight()] });
// Simulate having just typed "watch ==this==": put the text in the block,
// the caret at the end, and fire `input` (what the browser does after a
// keystroke).
const p = editor.content.querySelector("p")!;
p.textContent = "watch ==this==";
const sel = window.getSelection()!;
const r = document.createRange();
r.selectNodeContents(p);
r.collapse(false);
sel.removeAllRanges();
sel.addRange(r);
editor.content.dispatchEvent(new Event("input", { bubbles: true }));
// The rule fired, and the runner parked the caret after a ZWSP outside the
// new mark…
assert.ok(editor.getHTML().includes("this"));
assert.ok(editor.getHTML().includes("\u200b"));
// …which the serializer strips: the Markdown is clean.
assert.equal(editor.getMarkdown(), "watch ==this==");
editor.destroy();
```
### The keybinding and the command, live
Keybinding syntax is `[Mod-|Ctrl-|Alt-|Shift-]*Key`, where `Mod` is ⌘ on
macOS and Ctrl elsewhere. A binding is either a command name or a handler
`(ctx, event) => boolean` — return `true` to consume the event. `definePlugin`
validates key strings upfront (unknown modifiers throw).
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite } from "edodo-write";
import { highlight } from "edodo-write/plugins";
// jsdom lacks Range.getClientRects — stub it once for selection-based tests
// (see "Testing your plugin" below). Real browsers never need this.
if (typeof Range.prototype.getClientRects !== "function") {
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = () => new DOMRect();
}
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, { value: "glow up", plugins: [highlight()] });
// Focus FIRST, then select (jsdom's focus() resets a selection made before).
editor.focus();
const p = editor.content.querySelector("p")!;
const sel = window.getSelection()!;
const r = document.createRange();
r.selectNodeContents(p);
sel.removeAllRanges();
sel.addRange(r);
// Mod-Shift-H → the plugin's binding → exec("highlight") → .
editor.content.dispatchEvent(
new KeyboardEvent("keydown", { key: "h", metaKey: true, shiftKey: true, bubbles: true, cancelable: true }),
);
assert.equal(editor.getMarkdown(), "==glow up==");
// The command toggles: exec-ing it again (caret is inside the mark) unwraps.
assert.equal(editor.exec("highlight"), true);
assert.equal(editor.getMarkdown(), "glow up");
editor.destroy();
```
### Prove the round-trip
If your plugin touches Markdown syntax, this test is not optional — it is the
plugin contract's teeth (details in
[the round-trip contract](#the-round-trip-contract)):
```ts
import { strict as assert } from "node:assert";
import { createCodec, assertRoundTrip } from "edodo-write/testing";
import { highlight } from "edodo-write/plugins";
// The exact parse/serialize codec an editor with these plugins would use.
const codec = createCodec([highlight()]);
assertRoundTrip(codec, "some ==highlighted== words"); // throws on divergence
assert.ok(codec.parse("==hi==").includes("hi"));
assert.equal(codec.serialize("
hi
"), "==hi==");
```
## Typed commands
Command names live in the `CommandPayloads` interface. Because it is an
interface (not a closed union), plugins extend it with TypeScript **module
augmentation**, and their commands become first-class citizens of
`editor.exec` / `ctx.exec`:
```ts no-run
import { definePlugin } from "edodo-write";
declare module "edodo-write" {
interface CommandPayloads {
highlight: void; // no payload
myEmbed: { url: string }; // payload required
}
}
export const myEmbed = definePlugin({
name: "my-embed",
commands: {
myEmbed: {
run: (ctx, payload: { url: string }) => {
// …insert the embed at the caret…
void ctx; void payload;
},
},
},
});
// Now, in any file that sees the augmentation:
// editor.exec("myEmbed", { url: "https://…" }) — fully typed
// editor.exec("myEmbed") — compile error (payload missing)
// editor.exec("highlight") — ok (void payload = no argument)
```
The mechanics, and their edges:
- **`PayloadArgs`** makes the payload argument *required exactly when the
declared payload isn't `void`*. `exec("bold")` takes no second argument;
`exec("link", { href })` demands one.
- **`AnyCommand` is the escape hatch** for dynamic dispatch and plain JS: it
autocompletes declared names but admits any string, with the payload typed
`unknown`. Plain-JS plugin authors lose nothing.
- **Augmentation can fail silently.** Under unusual `moduleResolution`
settings (or when the augmented module specifier doesn't match how you
import the package), TypeScript quietly ignores the `declare module` block
and your command name falls back to the `AnyCommand` string case — no error,
just weaker types. The runtime is the backstop: executing a name that was
never *registered* warns in the console and returns `false`; it never
throws.
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite } from "edodo-write";
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, { value: "hello" });
// The runtime backstop: unknown commands warn and return false — never throw.
const warnings: string[] = [];
const origWarn = console.warn;
console.warn = (msg: string) => { warnings.push(String(msg)); };
const result = editor.exec("not-a-command");
console.warn = origWarn;
assert.equal(result, false);
assert.ok(warnings.some((w) => w.includes('unknown command "not-a-command"')));
assert.equal(editor.getMarkdown(), "hello");
editor.destroy();
```
Note that registration itself is untyped on purpose — `commands` accepts any
string key (validated at runtime, collisions throw), so a JS-only plugin works
identically; the augmentation only adds compile-time safety for callers.
## Block plugins: callout
Inline marks wrap a range; **block plugins restructure the caret's block**.
The shipped example is `src/plugins/callout.ts` — Notion-style callouts stored
as GitHub alert syntax, chosen precisely because it degrades to a plain
blockquote everywhere else (see [the degradation story](#the-degradation-story)):
```markdown
> [!NOTE]
> Useful information users should know.
```
In the editor a callout is `
…
`.
The pieces, from the real source:
```ts no-run
// The block command: manual DOM via ctx.dom — never execCommand for block
// structure (execCommand block ops are silently dropped inside `input`
// events, exactly where input rules run).
const calloutCommand = {
run: (ctx, payload?: { kind?: string }) => {
const block = ctx.dom.currentBlock();
if (!block) return false; // refuse → exec returns false
const kind = payload?.kind ?? "note";
if (block.tagName === "BLOCKQUOTE") { // already a quote: upgrade
block.setAttribute("data-callout", kind);
return;
}
const bq = document.createElement("blockquote");
bq.setAttribute("data-callout", kind);
while (block.firstChild) bq.appendChild(block.firstChild);
ctx.dom.ensureNotEmpty(bq); // empty block = unplaceable caret
block.replaceWith(bq);
ctx.dom.placeCaretAtEnd(bq);
},
isActive: (ctx) => !!ctx.dom.currentBlock()?.hasAttribute("data-callout"),
};
// The within-scoped input rule: `> ` already became a blockquote (core rule);
// typing `[!note] ` INSIDE one upgrades it. `within` scopes the rule to
// blockquotes (the default is plain paragraphs).
const calloutRule = {
kind: "block",
within: ["BLOCKQUOTE"],
trigger: /^\[!(note|tip|important|warning|caution)\] $/i,
apply: (ctx, match, block) => {
block.setAttribute("data-callout", match[1].toLowerCase());
ctx.dom.deleteLeadingChars(block, match[0].length);
return true; // "I changed the document"
},
};
// Slash items carry payloads to one command — one command, many entries.
const calloutSlashItems = [
{ id: "callout-note", title: "Callout", group: "Media", command: "callout", payload: { kind: "note" } },
{ id: "callout-warning", title: "Warning callout", group: "Media", command: "callout", payload: { kind: "warning" } },
];
// The parsed HTML carries data-callout — widen the sanitizer for it.
// Widening is ADDITIVE only: the denial floor (scripts, iframes, event
// handlers, script-scheme URLs) is not negotiable.
const calloutSanitize = { attributes: { blockquote: ["data-callout"] } };
```
The full file adds the paired marked renderer + turndown rule (the same
pairing discipline as highlight). Now the plugin in action:
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite } from "edodo-write";
import { callout } from "edodo-write/plugins";
// Stored GitHub-alert markdown hydrates into a decorated block…
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, {
value: "> [!NOTE]\n> Useful information.",
plugins: [callout()],
});
assert.ok(editor.getHTML().includes('data-callout="note"'));
assert.ok(!editor.getHTML().includes("[!NOTE]")); // the marker is structure, not text
assert.equal(editor.getMarkdown(), "> [!NOTE]\n> Useful information.");
editor.destroy();
// …and the command converts the CARET block (payload picks the kind).
const host2 = document.createElement("div");
document.body.appendChild(host2);
const editor2 = new EdodoWrite(host2, { value: "Ship it", plugins: [callout()] });
const p = editor2.content.querySelector("p")!;
const sel = window.getSelection()!;
const r = document.createRange();
r.selectNodeContents(p);
r.collapse(false);
sel.removeAllRanges();
sel.addRange(r);
editor2.exec("callout", { kind: "warning" }); // typed via the plugin's augmentation
assert.equal(editor2.getMarkdown(), "> [!WARNING]\n> Ship it");
editor2.destroy();
```
The `within`-scoped rule, firing as you type inside a quote:
```ts
import { strict as assert } from "node:assert";
import { EdodoWrite } from "edodo-write";
import { callout } from "edodo-write/plugins";
const host = document.createElement("div");
document.body.appendChild(host);
const editor = new EdodoWrite(host, { value: "> plain quote", plugins: [callout()] });
// Simulate having typed "[!warning] " at the start of the blockquote.
const bq = editor.content.querySelector("blockquote")!;
bq.textContent = "[!warning] plain quote";
const sel = window.getSelection()!;
const r = document.createRange();
r.setStart(bq.firstChild!, "[!warning] ".length);
r.collapse(true);
sel.removeAllRanges();
sel.addRange(r);
editor.content.dispatchEvent(new Event("input", { bubbles: true }));
assert.equal(editor.getMarkdown(), "> [!WARNING]\n> plain quote");
editor.destroy();
```
Two orderings in that rule are load-bearing, and both come from the engine's
hard-won invariants:
- **Convert before strip.** A rule must transform the still-non-empty block
*first*, then delete the trigger text (commands no-op on empty blocks).
Rules with `apply: "commandName"` inherit the whole
convert → strip → re-anchor sequence — plus a single `transact()` around it
so no half-done state hits undo history. Only function-`apply` rules manage
the order themselves.
- **`ctx.dom.deleteLeadingChars` is checkbox-safe.** It anchors the deletion
range to the first *text* node, never `(block, 0)`, so a leading task-list
checkbox is never swept into a trigger deletion.
### The degradation story
This is the **required degradation policy for all plugin syntax**: a document
written with your plugin must stay valid, lossless Markdown for editors (and
renderers, and LLMs) that don't have it. GitHub alert syntax passes: without
the callout plugin, `> [!NOTE]` is just a blockquote whose first line reads
`[!NOTE]` — visible text, zero data loss. Design your syntax so that the
un-plugged reading is acceptable; if losing the plugin would destroy content
or produce garbage, choose a different mapping (this is why edodo-write maps
callouts to blockquotes and rejects syntaxes with no plain-Markdown form).
```ts
import { strict as assert } from "node:assert";
import { createCodec } from "edodo-write/testing";
import { callout } from "edodo-write/plugins";
const md = "> [!NOTE]\n> Useful information.";
// An editor WITHOUT the plugin: the callout renders as an ordinary
// blockquote, the marker as visible text — valid GFM, nothing lost.
const plain = createCodec([]);
const degradedHtml = plain.parse(md);
assert.ok(degradedHtml.includes("
"));
assert.ok(degradedHtml.includes("[!NOTE]"));
assert.ok(!degradedHtml.includes("data-callout"));
// If that plugin-less editor re-saves, the marker text survives (turndown
// escapes the brackets, as it does for any literal ones)…
const resaved = plain.serialize(degradedHtml);
assert.equal(resaved, "> \\[!NOTE\\] Useful information.");
// …and an editor WITH the plugin re-hydrates even the escaped form back into
// a decorated callout, and normalises it to canonical syntax on save.
const decorated = createCodec([callout()]);
const rehydrated = decorated.parse(resaved);
assert.ok(rehydrated.includes('data-callout="note"'));
assert.equal(decorated.serialize(rehydrated), md);
```
## Widget plugins (source-carrying blocks)
Some blocks are not editable text at all — a rendered diagram, a display
equation, a media embed. The shared machinery in `src/plugins/widget.ts`
(exported from `edodo-write/plugins`) implements them as
``: the **source lives in the
`data-source` attribute**, which is the single thing your turndown rule
serializes back to Markdown — so the rendered view can be anything (SVG,
iframe, card) without ever touching the round-trip.
| Helper | What it does |
|---|---|
| `createWidget(kind, source)` | Build the figure for insertion: `contenteditable="false"`, with a dedicated render surface as its only child. |
| `mountWidgets(ctx, spec)` | Reconcile all figures of `spec.kind` under the root: (re)render any whose source changed since the last pass, skip the rest (tracked via `data-rendered`). Idempotent and cheap — call it from `setup` **and** `on.change`. `spec.render` may be async; a rejection renders a readable error box instead of breaking the editor. |
| `wireWidgetEditing(ctx, spec)` | Click-to-edit: by default a source-textarea popover with Save/Cancel (saving is one transaction + a re-render); pass `edit: false` to disable or a custom handler. Returns the cleanup — return it from `setup`. |
| `escapeAttr(value)` | HTML-escape a source string for embedding in a `data-` attribute from a marked renderer (newlines survive attribute round-trips). |
```ts
import { strict as assert } from "node:assert";
import { createWidget } from "edodo-write/plugins";
const figure = createWidget("my-widget", "raw source");
assert.equal(figure.tagName, "FIGURE");
assert.equal(figure.getAttribute("data-widget"), "my-widget");
assert.equal(figure.getAttribute("data-source"), "raw source");
assert.equal(figure.getAttribute("contenteditable"), "false");
```
The engine already treats `FIGURE` as a first-class block: Enter escapes to a
paragraph below it, Backspace before it deletes it whole (one undoable step,
Notion-style), drag reorders it, and the block menu hides *Turn into* for it.
Your plugin contributes only the widget-specific parts:
- the **paired markdown extension** — a marked renderer/tokenizer that emits
the figure (use `escapeAttr` for the attributes) and a turndown rule that
writes `data-source` back to your syntax;
- a `sanitize` widening for the figure's tags/attributes **when the figure
comes from parsing** (`math()` and `diagrams()` declare
`{ tags: ["figure"], attributes: { figure: [...] } }`; `embeds()` needs
none because its figures are created by a DOM reconciliation pass, never by
the parser);
- `setup` (mount + wire editing, return the cleanup) and `on.change`
(re-mount).
One turndown trap the shipped plugins already solve: turndown routes "blank"
nodes past the rule array entirely, and a widget that has not rendered yet
**is** blank — a save landing in that window would silently drop the block.
Either keep the figure non-blank from birth (the math approach: the surface
carries the source as text until the render replaces it) or shim the
serializer's blank rule for your figures (the diagrams/embeds approach).
Worked references, in reading order: `src/plugins/math.ts` (`$$` blocks — plus
inline chips outside the figure machinery), `src/plugins/diagrams.ts` (fenced
languages, per-language renderers), `src/plugins/embeds.ts` (widgets created
by reconciliation instead of parsing). Their user-facing behaviour is
documented in [First-party plugins](FIRST_PARTY_PLUGINS.md) — this section is
about building your own.
## The EditorContext reference
Every plugin entry point — commands, rules, key handlers, menu items, `setup`,
lifecycle hooks — receives the same `EditorContext`. It is bound to *this*
editor instance (no root parameter to pass wrong on multi-editor pages).
### `ctx.dom` — the caret-safe toolbox
These helpers encode the contentEditable invariants (the full catalog, with
the bug behind each rule, is in [DEVELOPMENT.md](DEVELOPMENT.md)). Use them
instead of re-deriving caret math:
| Helper | What it does — and which gotcha it encapsulates |
|---|---|
| `currentBlock()` / `currentListItem()` | The top-level block / `