editor .md

How it works

A page is a tree: each node names a component from the app's source, with its props, its styles and its text. The tree lives in a CRDT, so anyone can edit it at once and every copy ends the same. The editor draws the tree with the real components, so the page works while you edit it, and an edit only ever changes the tree, never the code.

Our own format, nobody's new engine

Should this be built from scratch, with a format of its own? Partly. The schema is ours: what a node is, which styles exist, how a component declares its props. The engines are not: the CRDT, the text editor and the components are mature open source, and the text form is markdown with tags.

The schema has to be ours because no existing format is all of these at once:

  • Data, never code. HTML carries scripts and handlers; MDX carries imports and expressions; the AI builders' format is code.
  • A tree whose nodes keep their identity through moves. HTML and markdown have no ids; ProseMirror's JSON and Puck's are trees, but their collaborative bindings store children in lists, so a move is a delete and an insert, and a concurrent edit to the moved block is lost.
  • Styles as data with a fixed list of properties. Webstudio and GrapesJS store full CSS, which is the right power for one designer and the wrong one for a stranger's edit.
  • Components from the app's own source. Puck gets this right, and its data model is the nearest to ours: a component name and its props.

What a format of our own costs: we write its migrations, its text form's parser and its binding to the editor, and nobody else's tools read it. The text form is markdown so that people and git can, and the schema is small so that the rest stays cheap.

A page in three forms

  • The doc (Loro) is the truth: the tree, every edit, who made it, and every version. It merges in any order.
  • The text form is what people, git and the source view read: markdown for prose, tags for components, with literal props and a stable id on each node; no imports, no expressions, nothing that runs. These pages are written in it.
  • Node JSON is a flat snapshot of one version, defaults stripped, checked against each component's schema; it is all a renderer reads, live or published.

A node is one LoroTree node with a map: type (a component's name), props (checked against the component's schema), style (property to value, from the allowed list) and, for nodes holding words, text, a LoroText with marks for bold, links and the like.

The text form is markdown for headings and paragraphs and a tag for every other component, with an id on every block: a trailing {#12@345}, or an id on the tag. Marks are toggles (**, _, a backtick, […](…)), so any overlap of them prints and parses back as it was. An id on every line costs some reading; matching blocks by their words instead would turn a move made in git into a delete and an insert, losing what move/keeps-edits keeps.

The source view shows the text form, printed from the doc as edits arrive. Editing it is harder than showing it: a changed line is diffed against the text it was printed from, and the difference becomes the edits a person would have made, so it merges with everyone else's. That diff is the same one an edit made in git needs, so the two are built once.

The technologies

Loro, for the doc

Loro is a CRDT in Rust with a WebAssembly build. It has the one thing this needs that the others don't: a movable tree, where moving a node is one operation, so a section dragged while someone types in it keeps their typing. It also has rich text with marks (Peritext's rules), undo that undoes only your own edits, presence, shallow snapshots, version history and forks, and it can read an update's authors before importing it, so an update can be refused whole.

Chosen over:

  • Yjs, the most used CRDT, with the most editor bindings. It has no move: a move is a delete and an insert.
  • Automerge. Well designed, with Ink & Switch's Keyhive work on encryption and permissions, but no movable tree yet.
  • Our own CRDT. Years of work that Loro has done.

Its cost is size: 3.4 MB of WebAssembly, 790 KB over the wire (loro-crdt 1.16.4, measured 2026-10-17). So a page paints first as static HTML, readable at once, and the editor loads behind it.

ProseMirror, for typing

Typing is where an editor feels right or broken: selection, input methods, spellcheck, paste, and undo all have to behave as they do everywhere else. ProseMirror handles them, and its schema is a list of what a document may contain, which suits a format that may contain only data.

We write its binding to the doc ourselves. The existing one, loro-prosemirror (0.4.4), stores children in lists, which would lose the movable tree. Its cursor and undo plugins are the model for ours.

Chosen over:

  • Tiptap, ProseMirror with a friendlier API and extensions. Its collaboration is built on Yjs, and its hosted parts are paid; we'd use ProseMirror underneath anyway.
  • Lexical. Fast and small, but its collaboration is Yjs too, and it assumes React.
  • contenteditable from scratch. Every editor that tried this rebuilt ProseMirror, slower.

Preact, for components

A component is a function from props to markup, built from the app's source, with a schema of its props beside it. Preact is 4 KB and runs React's components, so most of the React world's components can be registered.

Chosen over:

  • React, ten times the size for the same model.
  • Web components. No framework, but their shadow DOM hides elements from the style pencil and from the page's tokens.
  • Svelte or Solid. Smaller output, smaller ecosystem to register from.

CSS tokens and one stylesheet, for styles

Tokens are CSS custom properties (--accent, --space-2). A node's style map becomes rules in one constructed stylesheet, keyed by the node's id, rebuilt as edits arrive. Nothing is written into style attributes, so the page's CSP can forbid inline styles, and a value is checked against the allowed list before it reaches the sheet.

The browser's own channel and storage, for sync

Each tab is a peer with its own copy of the doc. Tabs send each other updates over a BroadcastChannel, which the browser delivers between tabs of one site and nowhere else, and the doc is kept in IndexedDB, so a page outlives its tabs. A tab that opens late, or was cut off, sends its version and gets back only what it is missing. One tab at a time, chosen by a Web Lock, writes the compacted snapshot.

The editor sees none of that: it hands a transport bytes to send and takes the bytes it receives. The browser's channel is the first transport; your devices are the next one (Sharing).

Chosen over:

  • A relay in the cloud (a Durable Object per page). It would reach other devices today, but it means storing everyone's pages, sealing them so we can't read them, and running a service; your.os does all of that already.
  • Browsers connecting directly (WebRTC). It still needs a server to introduce them and, on about one network in five, a relay of its own. That is your.os's job too.
  • One shared copy that tabs take turns writing. Simpler, but then two tabs aren't two peers, and two tabs are the cheapest real test of the merging everything else depends on.

TypeScript and esbuild, for the editor and the build

The editor is large enough that types pay for themselves; esbuild builds it in milliseconds with no configuration. The build is also the gate for code: a component reaches the library only through it.

Docker and Playwright, for the tests

One image (Playwright 1.63 on Ubuntu, with wrangler) builds the site, serves it locally exactly as it ships, and drives real Chromium against it, several tabs at once for the editing tests. npm run e2e is the whole thing, so a laptop and CI run the same.

This site

Each page in docs/ is rendered by site/build.mjs to static HTML, with the page's markdown beside it for agents. It is the publish path the editor will use, in its first form.

No server: privacy, comments and AI

There is no server for pages. The site serves the editor's files, the same to everyone, and after they load, a page is edited, stored and shared inside your browser.

What anyone else sees

The site sees that someone loaded the editor, from which IP address, as any website does. It never sees a page, a comment or an edit, because none is ever sent; a test proves no request is made while editing.

Comments

A comment is part of the page's data, in the same doc. It is anchored to a node id, or to a stable cursor into a node's text (Loro keeps these through edits), so it follows what it's about. A comment from someone on another device arrives the way their edits do, once your.os carries them.

AI

With no server, an AI runs from your browser, as you: when you ask, it sends the part you selected to the provider you chose (Anthropic or OpenAI), with your own key. The provider reads what you sent, the way a person you showed it to would, and nothing else leaves. The editor's CSP lets it reach those two and nowhere else.

The AI doesn't get to write code, or even text the page would parse: it gets tools for the same moves a person makes (insert a component, set a prop, set a style from the allowed list, move a block, change text), and its edits land on a copy of the page as a suggestion. Someone accepts them, and they merge as the AI's own edits, under its name, undoable on their own. Edits that are data are what make an AI safe to let in: the worst it can do is a bad edit you can see and refuse.

A model running inside the browser (WebGPU) would let the page never leave at all. Today such models are small enough to rename and restyle, not to build a page; it's an option for later, not the default.

Sharing: tabs first, then your devices

Two tabs in one browser need no coordination beyond the browser: no accounts, no addresses, no network. That is enough to build and prove all of the editing, because two tabs are two peers, merging the same way two devices would.

Between devices, the editor will run inside your.os, which already holds the hard parts: a key per device, sealed sessions between them, a direct connection where the network allows one, and a relay that can't read where it doesn't. your.os becomes one more transport, and who may edit a page becomes one of its grants. Until then, a page moves between devices as a file.

What waits for that: editing with someone on another device, and their comments.

What it costs to run

Nothing, except the AI. Cloudflare's prices, checked 2026-10-17:

  • The site, the editor and its WebAssembly are static files, and requests to static assets are "free and unlimited" on any plan. There is no other service to run, and no pages to store.
  • The AI is the one real cost. On Cloudflare's own models, a mid-size model (Llama 3.3 70B) is $0.29 per million tokens read and $2.25 per million written, after 10,000 free "neurons" a day for the whole account, which is a few dozen edits. A free tier for everyone would be paid by us, so it is bring your own key. A shared allowance would need a server holding our key, which nothing else here needs.

Open

  • Undo for a source edit made while someone else edited. It is made on a copy of the page as it was printed and merged, so it arrives as another peer's edit, and Ctrl+Z doesn't take it back. One made with nobody editing is undone like any other.
  • A version that brings back a deleted block brings it back with a new id: Loro can't undelete a tree node. The page renders the same; a comment on the old block stays with the old id.
  • One ProseMirror over the page, or one per text block. It is one per block for now: components stay out of ProseMirror's way, Enter splits a block and Backspace joins two. What it lacks is a selection across blocks, as in a document; whether that needs one ProseMirror is open.
  • A component whose code changes its schema. Its existing pages need a migration, applied as edits, so browsers on the old build keep working.
  • Storage the browser may clear. Browsers evict a site's storage under pressure unless asked to keep it, and some ask the user. How hard to nudge toward saving a file is open.
  • What your.os's transport needs from the editor: a way to say which peer an update came from, so a grant can refuse it whole. Loro can read an update's authors before importing it.