npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@robonen/writekit

v0.0.4

Published

Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT

Readme

@robonen/writekit

A headless, block-based rich-text writekit for Vue 3 — in the spirit of Tiptap / ProseMirror / Editor.js, but with a hand-built CRDT for collaboration (no Yjs/Loro/Automerge).

  • Block registry + schema — blocks and inline marks are modular, registered through a registry that projects an immutable schema.
  • Single contenteditable — one editable surface (ProseMirror/Tiptap model), so native cross-block mouse selection and arrow navigation behave like a normal document.
  • Framework-agnostic core — the model, schema, registry, state, commands and keymap are DOM-free and Vue-free; the Vue layer is only rendering + input.
  • Step-based transactions with exact inverses → real undo/redo, and the same steps drive the CRDT.
  • Own CRDT (@robonen/crdt) behind a pluggable CrdtProvider — RGA text, fractional-indexed blocks, Peritext-style marks, version-vector sync, presence/cursors.

Status: v0 / work in progress. Logic is covered by unit + convergence tests; the contenteditable/Playwright tests run locally (not in CI sandboxes).

Install

pnpm add @robonen/writekit @robonen/crdt vue

Quick start

<script setup lang="ts">
import { createDefaultRegistry, createWritekit, createWritekitState, WritekitRoot } from '@robonen/writekit';

const registry = createDefaultRegistry();
const writekit = createWritekit({ state: createWritekitState({ registry }) });
</script>

<template>
  <WritekitRoot :writekit="writekit" autofocus class="writekit" />
</template>

WritekitRoot's default slot renders WritekitContent (the single contenteditable). Provide your own slot to add UI around it:

<WritekitRoot :writekit="writekit" autofocus>
  <WritekitContent />
  <WritekitBubbleMenu />   <!-- formatting toolbar on selection -->
  <WritekitSlashMenu />    <!-- `/` to insert blocks -->
</WritekitRoot>

The package is headless: it ships behavior and DOM structure (data-block-*, data-writekit-* hooks), not styling. See playground/src/App.vue for a complete stylesheet.

Blocks & marks

Built-in blocks (via createDefaultRegistry()): paragraph, heading (1–6), bulleted-list / numbered-list / todo-list (flat-with-indent), blockquote, code-block, callout, divider, image. Built-in marks: bold, italic, underline, strike, highlight, code, link.

Add your own — no core changes needed:

import { createRegistry, defineBlock, defineMark, defaultBlocks, defaultMarks } from '@robonen/writekit';

const spoiler = defineMark({
  type: 'spoiler',
  spec: { toDOM: () => ['span', { 'data-spoiler': '' }, 0], parseDOM: [{ tag: 'span[data-spoiler]' }] },
});

const registry = createRegistry({ blocks: defaultBlocks, marks: [...defaultMarks, spoiler] });

Editing UX

  • Slash menuWritekitSlashMenu: type / at the start of a line; items come from each block's meta.
  • Bubble toolbarWritekitBubbleMenu: floats over a text selection (positioned with @floating-ui/vue); override the buttons via its default slot (#default="{ active, toggle }").
  • Markdown input rules# →heading, - /* →bulleted list, 1. →numbered list, > →quote, [] →to-do.
  • Drag to reorder — pass draggable to WritekitRoot for per-block drag handles.
  • HotkeysMod-b/i/u, Mod-Shift-s (strike), Mod-e (code), Mod-z / Mod-Shift-z, Enter (split), Shift-Enter (hard break), Backspace/Delete at edges (merge), Mod-a (progressive select), Mod-Alt-1..6 (heading), Tab/Shift-Tab (list indent).

Commands

Commands are (state, dispatch?, view?) => boolean and power the keymap, UI, and programmatic edits:

import { toggleMark, setBlockType } from '@robonen/writekit';

writekit.command(toggleMark('bold'));
writekit.command(setBlockType('heading', { level: 2 }));

Called without dispatch they are a dry run (for disabled/active toolbar state).

Collaboration (own CRDT)

The writekit is CRDT-agnostic behind CrdtProvider. The built-in provider maps writekit steps to CRDT ops (blocks → fractional-indexed set, text → RGA, formatting → mark store) and syncs op batches over any transport.

import { bindCrdt, createNativeProvider } from '@robonen/writekit';

// First peer seeds the document.
const provider = createNativeProvider({ schema: registry.schema, doc: writekit.state.doc, user: { name: 'Alice', color: '#2563eb' } });
const binding = bindCrdt(writekit, provider);

// Wire a transport (BroadcastChannel / WebSocket / …):
provider.onLocalOps(bytes => channel.postMessage(bytes));
channel.onmessage = e => provider.applyUpdate(e.data);

// A joining peer starts empty and syncs:
//   const provider = createNativeProvider({ schema });
//   provider.applyUpdate(remoteFullState);   // = peerA.encodeDelta()

Presence/cursors travel on a separate ephemeral channel and render with WritekitRemoteCursors:

provider.onLocalAwareness(bytes => channel.postMessage(bytes));
const cursors = ref([]);
provider.onAwareness(next => cursors.value = next);
<WritekitRoot :writekit="writekit">
  <WritekitContent />
  <WritekitRemoteCursors :cursors="cursors" />
</WritekitRoot>

See the Collaboration demo in the playground for a full two-replica example.

Applying a remote change is per-block: bindCrdt reuses the node identity of every block a remote edit didn't touch, so only changed blocks repaint and the local caret in untouched blocks is undisturbed.

For long-lived sessions, compact tombstones once the replicas are quiesced and fully synced:

provider.gc(); // drops deleted characters / removed blocks safe to forget

Known limitations (documented, deferred)

  • A local caret does not auto-shift when a remote peer inserts text before it (the caret keeps its offset).
  • Concurrent split/merge of the exact same range can drop a mark recreated on the moved tail.
  • gc() is only safe at quiescence (no in-flight ops) — it has no built-in stability protocol; drive it from your sync layer.

Development

pnpm --filter @robonen/writekit test          # logic (jsdom) + CRDT convergence
pnpm --filter @robonen/writekit build         # tsdown (ESM + CJS + dts)
pnpm --filter @robonen/writekit-playground dev

See AGENTS.md for the architecture and contributor notes.