# Matra — full documentation > A headless rich text editor framework for the web, with a first-class > extension model. Every page of the documentation follows, in reading > order. Source: https://matrajs.com --- # Matra A headless rich text editor framework with a first-class extension API. - **No engine leakage** — the document model is plain JSON; no ProseMirror type appears in a public signature - **Plain objects, plain functions** — no `this`, no classes, no inheritance chains - **Inferred types** — adding an extension adds its commands, fully typed, with no module augmentation - **Async-safe** — position mapping is built in, so a late AI response cannot corrupt the document See [DESIGN.md](./DESIGN.md) for the API rationale, [CHANGELOG.md](./CHANGELOG.md) for what changed when, and [CONTRIBUTING.md](./CONTRIBUTING.md) before a pull request. ## Packages | Package | Purpose | Licence | |---|---|---| | `@matrajs/core` | Engine, document model, extension API, starter kit | MIT | | `@matrajs/react` | `useEditor`, `useEditorState`, `useEditorFocus`, `EditorContent` | MIT | | `@matrajs/vue` | `useEditor`, `useEditorState`, `useEditorFocus`, `EditorContent` | MIT | | `@matrajs/svelte` | `matra` — a `use:` action, the editor, and a state store | MIT | | `@matrajs/solid` | `createMatra` — the editor, a `mount` ref, and a state signal | MIT | | `@matrajs/ai` | Streaming edits that survive concurrent typing | Commercial | | `@matrajs/collab` | Authority, step rebasing, remote cursors | Commercial | | `@matrajs/versions` | Snapshots, a real diff between them, restore as one undo step | Commercial | Matra is মাত্রা — the horizontal line that runs across the top of Bengali script and holds a word together. Packages live under the `@matrajs` scope, matching matrajs.com. **Installing a binding installs the engine with it.** For a React application `pnpm add @matrajs/react` is the entire install: one package, and no third-party dependency arrives behind it. ## Quick start ```ts import { createEditor, starterKit } from '@matrajs/core' const editor = createEditor({ extensions: starterKit, content: '
Hello
', }) editor.mount(document.querySelector('#editor')!) editor.commands.toggleBold() ``` Every command comes from the array you passed. Nothing else is on `editor.commands`, and calling something that is not there is a compile error. ## The packages in detail Eight packages, one version number, released together. Every other package depends on `@matrajs/core` and on nothing else, so installing a binding installs the whole editor — there is no second package to remember, no `@matrajs/pm` to keep in step, and no peer range to resolve by hand. --- ### `@matrajs/core` — MIT The engine, and the only package that is not optional. The document model, transforms, position mapping, editor state and the editable view are written here, with **zero runtime dependencies**. ```sh pnpm add @matrajs/core ``` **Entry points** | Export | What it is | |---|---| | `createEditor(options)` | Builds an editor. The `extensions` array decides everything else about it. | | `buildSchema(extensions)` | The schema alone, for validating a document with no view and no DOM. | | `pos(…)`, `range(…)` | Constructors for the two position types. | | `starterKit` | Seventeen extensions in one array — document, paragraph, text, heading, blockquote, code block, bullet/ordered/list item, horizontal rule, hard break, bold, italic, strike, code, link, history. | | 79 named extensions | Every entry in [Extensions](#extensions), each importable on its own. | | Helpers | `tableOfContents(doc)`, `assignIds(doc)`, `commentRanges(doc)`, `activeSuggestion(editor)`, `searchEmoji(query)`, `youtubeId(url)`, `normalizeUrl(text)`, `fieldsIn(doc)`, `fillFieldsIn(doc, values)`, `hashtagsIn(doc)`, `parseDelimited(text)`, `dictationSupported()` — plain functions, not extensions. | | `toMarkdown`, `fromMarkdown` | Pure string work, so they run in Node, in a worker and at the edge. | | `…CSS` helpers | `placeholderCSS`, `commentCSS`, `taskListCSS`, `dragHandleCSS`, `suggestionCSS`, `searchCSS`, `lockedCSS`, `fieldsCSS`, `columnsCSS`, `footnotesCSS` and the rest — stylesheets to paste into an app rather than a stylesheet to import. | **`EditorOptions`** | Field | Type | Notes | |---|---|---| | `extensions` | `readonly AnyDef[]` | Declare it `as const`. The tuple is what makes the commands infer. | | `content` | `DocNode \| string` | Document JSON, or HTML to parse. | | `editable` | `boolean` | | | `autofocus` | `boolean \| 'start' \| 'end'` | | | `element` | `HTMLElement` | Mount as soon as the editor exists, instead of calling `mount` yourself. | **The editor** | Member | Signature | | |---|---|---| | `commands` | `CommandsOfHello
' }) const bold = useEditorState(editor, (e) => e.isActive('bold')) return ( <>`, and inside any node that says `code`, whitespace is literal.
- **Two editors from one extension array share the compiled schema**, the
parser, the serializer, the command table and the input rules.
- **Position markers are held weakly**, and the mapping log is swept every
256 changes for what no live marker still needs.
- **Attributes can be added to nodes another extension owns**, rendered onto
the element and read back on parse. `textAlign` now works on the paragraph
in the box; before, it silently did nothing unless you wrote your own.
## What typing costs
A keystroke is the operation everything else is measured against, and it took
three rounds of profiling to stop it costing the length of the document. The
current shape, and why each piece is that shape:
- **The document is rebuilt around one child, not by cutting.** An edit inside a
paragraph rebuilds every ancestor between it and the root. Doing that by
cutting the ancestor's children in two and appending the replacement back into
the middle walks the whole run three times and re-adds every child's size to
reach a total that differs from the old one by exactly one child.
`Fragment.replaceChild` copies the array once and does the size arithmetic in
a subtraction. Text is the exception — text nodes merge with their neighbours,
so the canonical form still has to be rebuilt when either side is text.
- **The diff asks before it touches the DOM.** `childNodes` is a live list, and
the patch loop used to index it for every child before deciding whether that
child was inside the edit at all. On two thousand blocks, 1999 of those reads
were thrown away.
- **The position map reuses its entries.** Re-recording is what a patch does to
every node whose subtree it kept, and a fresh entry object per node per edit is
garbage generated to say what the old object already said.
- **A full position-map backlog drops the backlog, not the document.** The map
absorbs each edit's mapping rather than rewriting every entry, and replays the
backlog when a cold entry is read. Past sixty-four pending edits the replay
costs more than saying where everything is again — which used to mean
rebuilding the whole document's DOM, at the cost of a rebuild every
sixty-fourth keystroke and the silent loss of every mounted node view's state.
The re-record happens after the patch, because before it the positions are
still in the coordinates the edit moved away from.
Measured in Node against happy-dom, that takes a keystroke on a
2000-paragraph document from 0.464 ms to 0.062 ms, and stops it tracking the
document's length: 0.045 ms at 20 blocks against 0.062 ms at 2000. In a browser
it is what put Matra ahead of Lexical on the row it used to lose.
The first render is a different problem with a different answer. It is within
about 15% of the floor — the cost of the browser creating the same elements with
no editor involved — so there is very little of it that is ours to remove. What
was ours: building into a document fragment and attaching it once rather than
appending block by block into a live tree, skipping the mark stack for children
that have no marks, and taking a direct path for the `[tag, 0]` shape most nodes
render as. Together, 0.77 ms to 0.59 ms for two hundred blocks in Node.
## What is deliberately not built yet
Honesty about the gaps, since "no dependencies" can read as "complete":
- ~~**Collaborative editing.**~~ Done in `@matrajs/collab`: an authority, step
exchange, rebasing of unsent work over remote edits, and remote cursors drawn
as decorations, each one mapped through local steps rather than clamped.
- ~~**Node views.**~~ Done. A node type may declare `nodeView`, returning its
own DOM plus an optional `contentDOM` for children. `stopEvent` keeps the
editor's hands off interactions inside the view.
Node views forced a real fix underneath: the renderer used to call
`replaceChildren()` on every keystroke, which is O(document) per character and
would have destroyed a view's focus, scroll position and any half-finished
interaction. It now patches. Because nodes are immutable, an edit inside one
paragraph leaves every other paragraph as literally the same object, so
identity alone skips most of the tree. Inline content inside a textblock is
still rebuilt whole — it is small, and mark wrappers make its DOM shape
diverge from the fragment.
- **Decorations.** No inline highlights or widgets independent of the document.
- **Drag and drop**, and **tables**.
- **Deep nesting in replace.** A cross-block range nested more than one level
deep returns null rather than guessing; it fails loudly, but it fails.
## Where the risk actually is
**Phase 3 is the correctness risk.** Position mapping is what makes a late AI
edit land on the right words. A subtle bug there corrupts documents silently,
which is the exact failure Matra is sold against. It needs property-based tests:
invert-and-reapply round-trips, mapping associativity, and fuzzed step sequences
compared against a reference implementation.
**Phase 5 remains the schedule risk, and shipping it does not end that.** The
view passes its tests in happy-dom, which is not a browser. IME composition for
CJK input, Android GBoard's after-the-fact corrections, spellcheck and
autocorrect mutating the DOM, and browser-specific selection bugs are found by
real users on real devices, not by unit tests. Treat the current view as
working-but-unproven until it has survived iOS Safari and Android Chrome, and
expect a tail of fixes there rather than a clean finish.
`harness/ime` is where that gets checked: a page to open on a real phone that
watches the document and the screen for the moment they disagree, logs the
composition events the browser actually sent, and walks a checklist of the cases
that break editors. Deliberately manual — the value is in the keyboards a device
farm does not have installed.
## Rules while this is in progress
- No ProseMirror type may enter a public signature. `types.ts` stays clean.
- Every phase keeps the full suite green; no phase lands with skipped tests.
- Bundle size is measured at each phase and recorded here, not estimated.
---
# Matra — core API design
Status: draft. Nothing published beyond reserved package names.
## Principles
1. **The engine never leaks.** No ProseMirror type appears in a public signature.
Raw access exists at `editor.unsafe`, is excluded from semver, and every use of
it is a bug report against this API.
2. **Plain data, plain functions.** Definitions are object literals. Commands are
ordinary functions. No `this`, no classes, no `.extend()` inheritance chains.
3. **Types are inferred, never declared twice.** Adding an extension to the array
adds its commands to `editor.commands` with full argument types. There is no
module augmentation step and no interface to keep in sync.
4. **Async is a first-class problem.** Positions drift while an AI call is in
flight. The API makes that safe by default rather than leaving it to callers.
## Three primitives
```ts
const heading = defineNode({
name: 'heading',
content: 'inline*',
group: 'block',
attrs: { level: { default: 1 } },
parseHTML: [{ tag: 'h1' }, { tag: 'h2' }, { tag: 'h3' }],
toDOM: (node) => [`h${node.attrs?.level}`, 0],
commands: {
setHeading: (ctx, level: 1 | 2 | 3) => ctx.setBlockType('heading', { level }),
},
keys: { 'Mod-Alt-1': 'setHeading' },
})
const bold = defineMark({
name: 'bold',
parseHTML: [{ tag: 'strong' }, { style: 'font-weight=bold' }],
toDOM: () => ['strong', 0],
commands: { toggleBold: (ctx) => ctx.toggleMark('bold') },
keys: { 'Mod-b': 'toggleBold' },
})
const editor = createEditor({ extensions: [heading, bold] })
editor.commands.setHeading(2) // typed
editor.commands.setHeading(9) // compile error
```
`defineExtension` is the third: no schema contribution, just commands, keys,
state and lifecycle. Everything that isn't a node or a mark.
## What differs from TipTap, and why
| TipTap | Matra | Reason |
|---|---|---|
| `addCommands() { return { cmd: () => ({ commands }) => ... } }` | `commands: { cmd: (ctx, ...args) => boolean }` | Three levels of currying collapse to one function. Arguments get real types instead of being erased. |
| `this.editor`, `this.options`, `this.storage` | everything passed as arguments | `this` is bound differently per hook and resists typing. Explicit arguments always work. |
| `Extension.create().extend()` | plain objects, composed | Inheritance chains make it impossible to know what a definition finally contains. |
| Commands merged into one global namespace | same, but collisions are a **compile error** | Two extensions declaring `toggleBold` should not silently shadow. |
| `declare module` augmentation for types | inferred from the extensions array | The augmentation step is the most common source of broken types in TipTap projects. |
| PM types in public API (`Node`, `Mark`, `EditorState`) | plain JSON `DocNode` | Swapping or upgrading the engine becomes possible without a breaking release. |
## Async and position drift
The hard problem in an AI editor: you send a paragraph to a model, the user keeps
typing, the response arrives three seconds later, and every position you captured
is now wrong. Written naively this corrupts documents.
`ctx.mark()` takes a marker that maps positions through every intervening change:
```ts
const rewrite = defineExtension({
name: 'ai-rewrite',
commands: {
rewrite: (ctx) => {
const marker = ctx.mark()
const range = ctx.selection
const text = ctx.doc // read what we need now
void ai.rewrite(text).then((result) => {
// the user may have typed anywhere in the meantime
editor.commands.replaceRange(marker.mapRange(range), result)
})
return true
},
},
})
```
This is why the AI layer belongs in core's design even though it ships as a
separate package. Retrofitting position mapping later is not possible.
## Open questions
- **Collaboration.** Y.js maps positions through its own type. `PosMarker` and
Y.js relative positions need to be one concept, not two. Decide before 0.1.
- **`batch()` rollback.** Rolling back when any command returns false is stated
in the types but interacts with input rules in ways not yet worked out.
- **Node views.** Framework-specific by nature. Core should expose a renderer
interface that `@matrajs/vue` and `@matrajs/react` implement, but the shape of
that interface is undecided.
- **Schema ordering.** ProseMirror's first node becomes the doc's default content.
Currently implicit via `priority`; may need to be explicit.
## Verified
`packages/core/src/types.test-d.ts` is a compile-time test. It asserts that valid
calls typecheck and that wrong arity, wrong argument types, and unknown commands
all fail. Run with `tsc --noEmit --strict`.
---
# Benchmarks
Run them yourself: `node bench/bench.mjs`.
## Bundle
An app importing the editor and the starter kit, bundled with esbuild,
minified, gzipped:
| | minified | gzipped |
|---|---|---|
| **Matra** | 94.6 kB | **31 kB** |
| Tiptap 3.30 | 370.5 kB | 117.2 kB |
**3.8× smaller.** Matra has no runtime dependencies; a React install of Tiptap
resolves 50 packages, 22 of them outside the `@tiptap` scope — mostly
ProseMirror. Counted by [`scripts/rivals.mjs`](./scripts/rivals.mjs), which
asks npm to resolve the install rather than trusting a number typed here. The figure was 25 kB at 0.16; 1.0
spent 3.6 kB on the engine doing more — attributes one extension adds to
another's nodes, paste and drop hooks, files and text dropped from outside,
blocks inserted into the middle of a paragraph, decorations compared after
mapping, paragraphs patched in place, positions found by bisection — and
every one of those is on the runtime side of this file.
## Speed, in a browser
Four editors mounted in the same page, in the same run, in Chrome 152 on
2026-09-05 — Matra 1.0.1 against Tiptap 3.31, Lexical 0.50 and Slate 0.126.
Each cell is the median of three runs of a median of seven samples.
Milliseconds, lower is better. `bench/browser` builds and runs this.
**No editor** is the same paragraphs built by hand into a `contenteditable`
div, with nothing else in the page — the floor none of these can go under.
| operation | No editor | Matra | Tiptap | Lexical | Slate |
|---|---|---|---|---|---|
| parse a document, 2000 ¶ | — | **2.2** | 8.1 | 35.8 | — |
| `getHTML()`, 2000 ¶ | — | **0.3** | 1.7 | 5.3 | — |
| keystroke, 200 ¶ | — | **0.073** | 0.158 | 0.102 | — |
| keystroke, 2000 ¶ | — | **0.480** | 0.622 | 0.547 | — |
| mount + first render, 200 ¶ | 1.0 | **1.2** | 4.6 | 2.3 | 5.0 |
| mount + first render, 2000 ¶ | 12.2 | **14.6** | 34.1 | 21.6 | 50.2 |
**Absolute milliseconds only mean anything within one run.** Same harness, same
browser, a different day: Lexical parsed the same document in 34.7 ms one week
and 71.6 ms the next, without a line of it changing. That is why all four are
mounted in one page and measured in one pass, and why comparing a number here
against a number from an older copy of this file is comparing two machines.
**Every mount is checked, not trusted.** The harness mounts once more outside
the timing and looks for the last paragraph's text on screen. A reconciler that
returns before its DOM exists is the cheapest possible way to win the mount row,
and an earlier version of this harness reported a number for Slate when nothing
had been drawn at all.
**The keystroke row changed hands.** It used to be the row Matra lost. Measured
back to back against the same rivals in the same session:
| keystroke | before | after |
|---|---|---|
| 200 paragraphs | 0.246 | **0.127** |
| 2000 paragraphs | 1.465 | **0.847** |
What was costing it, all of it found by profiling rather than by reading:
- **Every ancestor of the edit was rebuilt by cutting.** A paragraph changes and
each ancestor up to the document is rebuilt around it — by cutting the run of
children in two, appending the replacement, appending the rest, and re-adding
every child's size to get a total that differed from the old one by exactly
one child. On a 2000-block document that is four walks of two thousand
children per character. `Fragment.replaceChild` swaps the one child that moved
and does the arithmetic in one subtraction.
- **The diff reached into the DOM for blocks it had already decided to skip.**
The loop read `childNodes[i]` before asking whether child `i` was inside the
edit at all. `childNodes` is a live list, and for 1999 of 2000 blocks the
answer was thrown away. Asking first, indexing second.
- **Every sixty-fourth keystroke threw the rendered document away.** The
position map absorbs each edit's mapping instead of rewriting itself, and
capped the backlog at 64 — past which the renderer rebuilt the entire
document's DOM. It was the backlog that had gone stale, not the DOM, so now
only the backlog is dropped and the positions are recorded again in place. A
rebuild also silently dropped every mounted node view's state, which is a
correctness bug wearing a performance bug's clothes.
- **The position map allocated an entry per node per edit** to record a position
it already held. It reuses the entry now.
In Node, against happy-dom, where none of the browser's layout cost is in the
way, those take a keystroke on a 2000-paragraph document from 0.464 ms to
0.062 ms, and the cost stops tracking the length of the document: 0.045 ms at
20 blocks against 0.062 ms at 2000.
**The mount row was wrong, and the harness was why.** This file used to say
Lexical put an editor on screen faster. It does not. Teardown ran *inside* the
timed function and the layout read came after it, so an editor whose teardown
detaches its DOM had taken its document off screen before the browser was asked
to lay anything out. Lexical's teardown does that; Matra's drops listeners and
leaves the document where it is. Matra was paying for two thousand paragraphs of
layout and Lexical was not, on a row where layout is most of the number.
Teardown now runs after the clock stops and both mount rows changed hands. Same
class of mistake the harness already refused to make for Slate, one level up.
The first render did get faster while this was being chased — 0.77 ms to 0.59 ms
for two hundred blocks in Node — by building into a document fragment and
attaching it once rather than appending block by block into a live tree,
dropping the mark stack's three arrays per child for marks that blocks never
have, and taking a direct path for the `['p', 0]` shape most nodes render as.
**What is missing and why.** Slate's keystroke goes through a React render that
has not happened by the time the timer stops, and the harness checks whether the
text on screen changed during the measurement — when it did not, it reports
`NOT MEASURED` instead of a number. An earlier version of the harness cheerfully
reported Slate at 0.02 ms per keystroke, which was the model update with nothing
drawn behind it.
**What is not like-for-like.** Each editor is driven through its own idiomatic
API. Lexical and Slate carry the rich-text behaviour their own quick-starts
prescribe, which is not the same feature set as Matra's or Tiptap's starter kit.
## Speed, in Node
happy-dom, same document. Useful for the parts that never touch a DOM:
| operation | Matra | Tiptap | |
|---|---|---|---|
| create + parse a document | **0.70** | 21.62 | 31× faster |
| `getJSON()` | **0.16** | 0.11 | 1.5× slower |
## 1.0, in Node
The ratchet's own figures, before and after, in the calibrated units
`bench/bench.mjs` prints — the same machine, the same run, the floor of three
passes each:
| figure | 0.16 | 1.0 | |
|---|---|---|---|
| create editor, 50 ¶ | 17.5 | **3.7** | 4.7× |
| setContent JSON, 2000 ¶ | 127.2 | **80.4** | 1.6× |
| `getHTML()`, 2000 ¶ | 89.6 | **30.3** | 3.0× |
| `getJSON()`, 2000 ¶ | 11.3 | **9.7** | 1.2× |
| `getText()`, 2000 ¶ | 27.6 | **7.5** | 3.7× |
| insert one character | 2.73 | **0.22** | 12× |
| toggle bold over a range | 4.46 | **0.26** | 17× |
| keystroke, mounted, 500 ¶ | 5.04 | **2.17** | 2.3× |
And the ones the ratchet did not measure, in microseconds, because they are
where the time actually went:
| operation, 2000 ¶ | 0.16 | 1.0 | |
|---|---|---|---|
| keystroke at the **end** of the document, mounted | 180 | **34** | 5.3× |
| keystroke at the start, mounted | 77 | **48** | 1.6× |
| toggle bold on a word near the end | 453 | **20** | 23× |
| `isActive('bold')` + `isActive('heading')` | 1.47 | **0.12** | 12× |
| `createEditor`, empty | 85 | **16** | 5.3× |
| parse 2000 ¶ of HTML with marks (ms) | 77 | **55** | 1.4× |
What was costing it:
- **Every position was found by walking.** Resolving a position walked the
document's children from the first, adding sizes until it passed the point,
and a keystroke resolves a dozen positions. Typing at the end of a
two-thousand-block document cost twelve times what typing at the top did,
and the benchmark only ever typed at the top. A fragment past twenty-four
children now keeps a prefix index and bisects it.
- **A mark on one word rebuilt the whole document.** Asking "is this bold"
visited every node in the document to find the ones in the selection, and
applying the mark rebuilt every level from the first child to the last.
Both now walk only what the range touches, and only the children that
changed are swapped into their parent.
- **`toDOM` was handed a full JSON serialisation of the node.** Rendering a
paragraph serialised its text; rendering the document serialised the
document, once per level, so that a function returning `['p', 0]` could read
an attribute. It now gets an object that carries the type and the attributes
and builds the rest on demand.
- **Every command built a fresh object of twenty closures**, and every
`isActive` started a transaction to answer a question about the state. The
context is a class now and the transaction starts on first write, so
asking is free.
- **The character counter re-serialised the document on every click.**
Any extension reading `ctx.doc` in its reducer paid for the whole document
on every transaction, caret moves included. The counter reads the engine's
text and only when the document changed; `ctx.doc` is cached per document
within a command.
- **The drag handle asked the browser for every block's rectangle on every
mouse move.** Blocks stack, so the one under the pointer is found by
bisection: eleven rectangles on a two-thousand-block page, not two thousand.
- **The undo history copied the whole entry to add each keystroke to it.**
Inverses are appended and replayed from the end.
- **Every keystroke wrote the browser selection**, even when it was already
where it was about to be put, and every write came straight back as a
`selectionchange` event to be read and found identical.
- **A decoration anywhere threw the narrowed redraw away.** Last render's
decorations are now mapped through the edit before being compared, so a
search hit that merely moved is the same hit, and only the span where the
decorations really differ is added to what gets redrawn.
## Where the time went, the first time
Two earlier rounds, kept because both are the kind of thing that grows back:
- **The position map was rebuilt on every keystroke.** Every node was
re-recorded, so a 4000-paragraph document did eight thousand map writes per
character — all of them to say the same thing shifted by one. The map now
absorbs the transaction's mapping and translates positions when asked.
- **The diff visited every block.** It now skips any block the edit's span did
not touch: 3999 of 4000 children on a keystroke.
And one that was pure waste: `toJSON` called `Object.keys(attrs).length` to ask
whether a node had attributes, allocating an array per node to answer a
question about emptiness.
---
# Security
## The model
A rich text editor is a place untrusted content arrives from at least three
directions, and all three are treated as hostile:
| route | example |
|---|---|
| document JSON | a document loaded from your database, written by a user |
| pasted HTML | anything the clipboard contains |
| collaborative steps | a message from another client |
Validation that lives only in `parseDOM` or only in a command is bypassed by the
other two routes. **The last gate is the rendering path**, in
`engine/model/safe-attrs.ts`, which every route passes through.
## What is enforced
- **Executable attributes are never set.** Anything matching `on*`, plus
`srcdoc`.
- **URL attributes are scheme-checked** — `href`, `src`, `xlink:href`,
`action`, `formaction`, `poster`, `data`. `javascript:`, `vbscript:` and
`data:` are refused, except `data:image/*` on an `
`, which is a
legitimate inline image. The tag matters: the same bytes on an `