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

@meetreeve/editor

v0.4.4

Published

Reeve editor kit: tiptap schema factory, framework-agnostic autosave state machine, single-source word/character counts, and broadcast-only presence (avatars + live carets)

Readme

@meetreeve/editor

Reeve editor kit: a tiptap-based editor core extracted from Freya's SceneEditor. Ships a schema factory, a framework-agnostic autosave state machine, and a single source of truth for word/character counts.

Subpath entries:

| Entry | Contents | | --- | --- | | @meetreeve/editor / ./core | Schema factory, autosave machine, counts (DEV-7584); presence client (createPresenceClient, DEV-7928) | | ./ext | The 6 extensions lifted off Freya's book model (DEV-7585); BlockId, RemoteCarets (DEV-7928) | | ./ui | Chrome components: PresenceAvatars, usePresence (DEV-7928); toolbar to come (DEV-7586) | | ./ai | AI prose surface: transport seam, preview state machine, accept splice (DEV-7587) | | ./styles/tokens.css | --redit-* CSS tokens |

React, all @tiptap/* packages, yjs, and y-prosemirror are peer dependencies (yjs/y-prosemirror are optional; only needed when you plug in collaboration).

Mounting the core with a custom onPersist

import { useEditor, EditorContent } from '@tiptap/react';
import {
  createEditorExtensions,
  useAutosave,
  getCounts,
} from '@meetreeve/editor';
import '@meetreeve/editor/styles/tokens.css';

function MyEditor({ save }: { save: (html: string) => Promise<void> }) {
  const { status, noteChange, flush } = useAutosave<string>({
    onPersist: async (html) => {
      await save(html); // reject to surface a save error
    },
    debounceMs: 2000, // default
  });

  const editor = useEditor({
    extensions: createEditorExtensions({
      placeholder: 'Start writing your scene...',
    }),
    onUpdate: ({ editor }) => noteChange(editor.getHTML()),
  });

  return (
    <div>
      <span>{status}</span> {/* idle | dirty | saving | saved | error */}
      <EditorContent editor={editor} />
      <button onClick={() => void flush()}>Save now</button>
    </div>
  );
}

Outside React, use createAutosave directly; it is pure TypeScript:

import { createAutosave } from '@meetreeve/editor';

const autosave = createAutosave<string>({
  onPersist: (doc) => api.saveScene(doc),
});
const unsubscribe = autosave.onStatusChange((s) => render(s));
autosave.noteChange(editor.getHTML()); // on every edit
await autosave.flush(); // e.g. before navigation
autosave.dispose(); // on teardown
unsubscribe();

Status semantics follow Freya's deriveSaveStatus (DEV-2295): saving wins over everything, then error, then dirty, then saved. A rejected onPersist can never surface as saved; a new edit after a failure moves the status from error to dirty and schedules a retry.

Counts: one source of truth

getCounts(editor) and subscribeToCounts(editor, listener) read the live CharacterCount storage on the editor instance. That is the ONLY count source consumers may display.

Do NOT mix imported or backend-computed word/character counts with these live counts (for example showing a backend count until the first keystroke and a live count afterwards). That split is exactly what caused Freya bug DEV-2193: two counting rules disagree on whitespace and markup and the number visibly jumps. If a backend needs counts, send it the values from getCounts at save time.

import { getCounts, subscribeToCounts } from '@meetreeve/editor';

const { words, characters } = getCounts(editor);
const stop = subscribeToCounts(editor, ({ words }) => setWordCount(words));

Collab (Yjs) seam

The core does not ship a collaboration transport (the WebSocket server is DEV-3392, parked). What it ships is the extension slot: the extraExtensions option of createEditorExtensions is the seam where a consumer plugs @tiptap/extension-collaboration bound to its own Y.Doc and provider. Pass history: false in the same call; Yjs owns undo/redo and a second history extension would create a duplicate undo stack.

import Collaboration from '@tiptap/extension-collaboration';
import * as Y from 'yjs';
import { createEditorExtensions } from '@meetreeve/editor';

const ydoc = new Y.Doc();
// Bind ydoc to your provider of choice (Hocuspocus, y-websocket, ...).

const extensions = createEditorExtensions({
  history: false, // Yjs handles history
  extraExtensions: [Collaboration.configure({ document: ydoc })],
});

yjs and y-prosemirror are optional peer dependencies: install them only when you use this seam.

Presence (avatars + live carets)

Presence is the easy half of multiplayer, and it is NOT collaborative editing: broadcast-only who's-here, names, colours, and caret/selection positions (DEV-7928, ported from cloudflare/cloudflare-os @ aedcda8; see ATTRIBUTIONS.md). There is no CRDT and no convergence; simultaneous edits to the same block are last-write-wins, and PresenceAvatars says so in the UI. Convergent editing stays parked under DEV-3392.

The transport is the reeve-services presence lane (wss://api.meetreeve.com/api/presence/ws/{orgId}/{docId}?token=...). Presence never dials when the URL is unset (solo editing by design; no dead-host dials on mount, the DEV-3467 regression class).

import { useEditor, EditorContent } from '@tiptap/react';
import { createEditorExtensions } from '@meetreeve/editor';
import { BlockId, RemoteCarets } from '@meetreeve/editor/ext';
import { PresenceAvatars, usePresence } from '@meetreeve/editor/ui';

// clientId: stable per tab. Initialize once in tab-scoped storage so
// remounts (navigation, StrictMode) reuse the same identity instead of
// showing up as a new collaborator.
function tabClientId(): string {
  const existing = sessionStorage.getItem('redit-presence-client-id');
  if (existing) return existing;
  const id = crypto.randomUUID();
  sessionStorage.setItem('redit-presence-client-id', id);
  return id;
}

function DocEditor({
  presenceUrl,
  user,
}: {
  presenceUrl: string | null; // null → presence disabled, nothing is dialled
  user: { name: string };
}) {
  const editor = useEditor({
    extensions: createEditorExtensions({
      extraExtensions: [BlockId, RemoteCarets],
    }),
  });

  const { collaborators } = usePresence({
    editor,
    url: presenceUrl,
    clientId: tabClientId(),
    name: user.name,
  });

  return (
    <>
      <header>
        <PresenceAvatars collaborators={collaborators} />
      </header>
      <EditorContent editor={editor} />
    </>
  );
}

BlockId stamps every textblock with a stable data-block-id (the coordinate space carets are addressed in); RemoteCarets renders the other collaborators' carets and selection highlights as ProseMirror decorations; usePresence wires the WebSocket client (4s heartbeats, 12s stale expiry, best-effort pagehide leave) to the editor. Everything is also available framework-agnostic from @meetreeve/editor/core (createPresenceClient, getSelectionPresence, subscribeToSelection).

AI prose surface (./ai)

The generalized version of Freya's prose-generation surface (DEV-7587, extracted from DEV-1937/1939/2293). Preview-first by construction: a generation result lives only in the preview state machine until Accept, the transport always requests with save_to_db: false, and Reject/dismiss never touches the document.

import {
  AIProseSurface,
  createHttpProseTransport,
} from '@meetreeve/editor/ai';

const transport = createHttpProseTransport({
  baseUrl: appConfig.llmApiBaseUrl, // supplied by the HOST app
  headers: () => ({ Authorization: `Bearer ${getToken()}` }),
  // paths: { generate: '/prose/generate', revise: '/prose/revise' } (defaults)
});

<AIProseSurface
  editor={editor}
  transport={transport}
  context={{ document_id: docId, project_id: projectId }}
  onApplied={() => markNextSaveAsAiGenerated()}
  onError={(message) => toast.error(message)}
  renderPreview={(slot) => <MyPreviewChrome {...slot} />} // optional; T3 chrome
/>;

ProseTransport is the consumer-supplied seam: implement generate(request) (Insert at cursor) and revisePassage(request) (Enhance a selection) against any backend. createHttpProseTransport is the generic default HTTP implementation. This package ships NO default endpoint: wiring the Reeve.LLM base URL (and auth) lives with the consumer/host app, exactly like the autosave onPersist seam.

Accept semantics, carried over verbatim from Freya:

  • Passage replace runs an exact-match staleness check first (textBetween(from, to) must equal the captured selection text). If the document changed under the open preview, accept returns a stale-selection error and does NOT splice; the preview stays open so the user can copy and apply manually. There is deliberately no fuzzy or whitespace-insensitive fallback.
  • The splice itself is deleteRange + insertContentAt in ONE chained command, so a single undo restores the original text.
  • Model markdown scaffolding ("Improved Version" labels, rationale sections, fences, bold/hr/list markers) is stripped before any content reaches the document (extractRevisedProse + sanitizeProse, DEV-2293).
  • A plain-text response (no HTML tags) has its blank-line-separated paragraphs wrapped in <p> before insertion, so tiptap's HTML parser keeps them as separate paragraphs instead of collapsing them into one (toParagraphHtml, DEV-9794).
  • Full-document replacement (replaceDocument) uses setContent(content, true): the emitUpdate flag keeps onUpdate firing so counts and autosave stay fresh (DEV-2301). All counts come from getCounts in ./core, the single count source.

AIProseSurface's default chrome is styled by @meetreeve/editor/styles/tokens.css — importing it is enough, no consumer CSS required (DEV-10206; before that, .redit-ai-* was the one surface the stylesheet had no rules for, so the Insert/Enhance row and the accept/reject preview rendered as unstyled inline text). The rules are token-only, so [data-redit-theme='dark'] and a tenant --redit-accent override retheme them like every other surface. Restyle by class — .redit-ai-surface, .redit-ai-toolbar, .redit-ai-action (.redit-ai-action-insert / .redit-ai-action-enhance), .redit-ai-panel, .redit-ai-panel-label, .redit-ai-instruction, .redit-ai-panel-actions, .redit-ai-cancel, .redit-ai-submit, .redit-ai-busy, .redit-ai-error, .redit-ai-preview, .redit-ai-preview-note, .redit-ai-preview-content, .redit-ai-preview-actions, .redit-ai-reject, .redit-ai-accept — or replace the preview wholesale with renderPreview.

Below React, the pieces compose individually: createProseGenerationController (framework-agnostic orchestration), createPreviewMachine (the idle -> requesting -> previewing machine with stale-generation fencing), captureSnappedSelection, and the splice helpers. useProseGeneration is the React hook underneath AIProseSurface.

Development

pnpm -F @meetreeve/editor build      # vite lib build (mjs + cjs + d.ts)
pnpm -F @meetreeve/editor test       # vitest
pnpm -F @meetreeve/editor typecheck  # tsc --noEmit