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

@clevertask/scribe

v0.1.25

Published

A Radix-based Tiptap rich text editor with a Notion-style block interface for viewing and creating content. Perfect for diverse needs like notes, documents, AI chat, Markdown parsing, and comments.

Readme

@clevertask/scribe

A versatile, block-based rich text editor for diverse applications, built with Tiptap and inspired by Notion's intuitive interface. @clevertask/scribe allows you to seamlessly view, create, and edit rich text content, with added support for Markdown parsing.

[!WARNING] Scribe is experimental and has not reached version 1.0. Its APIs, default extensions, editor behavior, and built-in UI can change between releases, including minor releases. Pin an exact package version and test each upgrade before deployment. Forks and contributions are welcome.

Features

  • Block-based Editing: Enjoy a familiar and intuitive Notion-style editing experience.
  • Markdown Support: Parse and render Markdown content effortlessly.
  • Markdown Paste: Paste plain-text markdown into the editor and have it converted into rich content automatically.
  • Table Authoring: Insert, resize, and edit tables with controls that stay next to the active table.
  • Experimental External Link Previews: Opt into Compact and Preview card presentations while keeping ordinary links available.
  • Versatile Integration: Easily integrate @clevertask/scribe into any project requiring rich text editing.
  • View and Edit: Seamlessly switch between viewing and editing modes.
  • Experimental Table of Contents: Subscribe to heading changes and render an app-owned table of contents outside the editor.

Table of Contents

Installation

npm install --save-exact @clevertask/scribe @tiptap/[email protected]

Scribe shares this exact ProseMirror runtime with consumer extensions. Keeping one installed version prevents identity conflicts between built-in and custom plugins.

Headless schema

Use the schema subpath when a server or migration tool must parse Scribe content without loading the React editor or Scribe's stylesheet:

npm install --save-exact @clevertask/scribe @tiptap/[email protected] @tiptap/[email protected] @tiptap/[email protected]
import { createScribeSchemaExtensions } from "@clevertask/scribe/schema";
import { generateJSON } from "@tiptap/html";

const extensions = createScribeSchemaExtensions({ enableUndoRedo: false });
const document = generateJSON(storedHtml, extensions);

The returned extensions define Scribe's persistent nodes and marks without loading Scribe's React editor UI. Use them with Tiptap's server-side HTML utilities; this is not a headless interactive editor. Scribe's menus, placeholders, suggestions, and table-of-contents behavior are not part of this entry point. Consumer-owned nodes must be appended to the list. For example, an application that stores its own resource-reference node must provide that extension before parsing or rendering content containing those references.

Pin Scribe and Tiptap to the exact versions used by the writing clients. This entry point makes the schema reusable; it does not make different schema versions interchangeable.

Scribe preserves inline code together with other text marks, such as bold and links. This applies only to inline code; code-block text remains unmarked.

Servers can also inspect Scribe's document-node capabilities through the same headless entry:

import {
  createScribeDocumentNodeCapabilityManifest,
  createScribeSchemaExtensions,
} from "@clevertask/scribe/schema";
import { getSchema } from "@tiptap/core";

const extensions = createScribeSchemaExtensions({ enableUndoRedo: false });
const capabilities = createScribeDocumentNodeCapabilityManifest(getSchema(extensions));

capabilities.paragraph.potentialOperations; // [{ type: "replace_content" }]
capabilities.callout.potentialOperations; // [{ type: "set_attributes", attributes: ["variant"] }]
capabilities.taskItem.potentialOperations; // [{ type: "set_attributes", attributes: ["checked"] }]
capabilities.tableCell.potentialOperations; // []

The manifest intentionally keeps structural list changes application-owned. A consumer can use the task-item attribute capability while defining its own bounded policy for adding or moving task-list items.

These are structural possibilities, not application permissions. An application must still check authorization, the current node and its ancestors, protected descendants, revision visibility, parser support, and size limits before it offers or performs a write.

If an application appends a consumer-owned node to the Scribe schema, it must also pass an exact capability declaration to createScribeDocumentNodeCapabilityManifest. Use defineScribeDocumentNodeCapability to type that declaration. Manifest creation fails when a node is missing, duplicated, or no longer matches the schema. A custom node can declare no potential operations and remain explicitly read-only.

Headless table transforms

The same headless entry can insert or delete an exact logical table row or column and can merge or split cells without mounting an editor or accessing the DOM:

import { applyScribeTableTransform, createScribeSchemaExtensions } from "@clevertask/scribe/schema";
import { getSchema } from "@tiptap/core";

const schema = getSchema(createScribeSchemaExtensions({ enableUndoRedo: false }));
const document = schema.nodeFromJSON(storedDocument);
const tablePosition = 42; // ProseMirror position immediately before the table node.
const result = applyScribeTableTransform(document, tablePosition, {
  type: "insert_row",
  index: 1,
});

persist(result.document);

Rows and columns use zero-based logical coordinates from ProseMirror's table grid, not physical child indexes. An insertion index may equal the current row or column count; deletion indexes must name an existing logical row or column. Merge rectangles use half-open bounds: topRow and leftColumn are included, while bottomRowExclusive and rightColumnExclusive are excluded. A split's row and column must identify the merged cell's top-left logical coordinate. Existing rowspans and colspans can therefore affect more than one physical cell even though the requested logical boundary remains exact. The returned before and after geometry reports logical rows, logical columns, and physical cell count.

applyScribeTableTransform is an immutable, exact-snapshot operation: it returns a new ProseMirror document and leaves its input unchanged. It does not rebase coordinates or detect that another writer changed the stored document after the input snapshot was read. On a concurrent change, discard the candidate, read the latest document, resolve the intended table and coordinates again, and rerun the transform. Invalid coordinates, malformed or nested tables, and operations that do not apply throw ScribeTableTransformError.

This API owns only schema-valid table geometry. The host application owns authorization, protected node rules, revision and conflict checks, destructive previews, payload limits, Yjs conversion and persistence, and durable readback. In CleverTask, those checks belong to the collaboration and API layers rather than Scribe.

Usage

Basic usage

import "@radix-ui/themes/styles.css";
import "@clevertask/scribe/styles.css";
import { Theme } from "@radix-ui/themes";
import { Scribe, ScribeRef } from "@clevertask/scribe";

function App() {
  const onContentChange = useCallback(
    ({ markdownContent, htmlContent, jsonContent }: ScribeOnChangeContents) => {
      console.log(markdownContent, htmlContent, jsonContent);
    },
    [],
  );

  return (
    <Theme>
      <Scribe onContentChange={onContentChange} />
    </Theme>
  );
}

With ref

import "@radix-ui/themes/styles.css";
import "@clevertask/scribe/styles.css";
import { Theme } from "@radix-ui/themes";
import { Scribe, ScribeOnChangeContents } from "@clevertask/scribe";

function App() {
  const editor = useRef<ScribeRef>(null);

  const resetContent = useCallback(() => {
    editor.current.resetContent();
  }, []);

  return (
    <>
      <Theme>
        <Scribe ref={editor} />
      </Theme>
      <button onClick={resetContent}>Reset content</button>
    </>
  );
}

Using Your App Theme

import "@radix-ui/themes/styles.css";
import "@clevertask/scribe/styles.css";
import { Theme } from "@radix-ui/themes";
import { Scribe } from "@clevertask/scribe";

function App() {
  return (
    <Theme appearance="dark">
      <Scribe />
    </Theme>
  );
}

Table Authoring

Type /table to insert a 3 × 3 table with a header row. Selecting a table cell opens nearby controls for adding or deleting rows and columns, toggling the header row, and deleting the table. Drag a column boundary to resize it.

Keyboard users can press Alt + F10 while editing a table to focus its controls, use the arrow, Home, and End keys to move between actions, and press Escape to return to the active cell.

Simple headed tables serialize as GFM Markdown. Tables with merged cells, multiple blocks in a cell, resized columns, or other structures that GFM cannot represent are kept as sanitized raw HTML inside the Markdown output so their structure is not silently lost.

Scribe's insertTable command and /table action do not create a table while the selection is already inside another table. Existing documents containing nested tables remain loadable so old content is not destroyed. This guard covers Scribe-owned authoring paths only: arbitrary consumer calls to insertContent, Markdown table paste, and rich-HTML table paste remain trusted integration points. Applications that require a hard no-nested-table guarantee must validate or filter those paths too.

External Link Previews

[!NOTE] External Link Previews are experimental. Their public API, built-in card presentation, and link-options UI may change as we test them in real document workflows.

Compact links work locally without a metadata provider. Scribe builds their visible label from authored link text or, for a raw URL, from its hostname and path. The exact destination—including its query and fragment—remains unchanged. Preview cards are opt-in: Scribe owns their editor behavior and presentation, while your app owns metadata fetching. Pass a resolver that calls an authenticated app endpoint; Scribe never requests the destination website directly.

That endpoint should validate the destination, block private or reserved network addresses, re-check redirects, and enforce response-size and timeout limits before returning sanitized metadata.

import { Scribe, type ExternalLinkPreviewResolver } from "@clevertask/scribe";
import { useCallback } from "react";

function DocumentEditor() {
  const resolveLinkPreview = useCallback<ExternalLinkPreviewResolver>(async (href, { signal }) => {
    const response = await fetch(`/api/link-previews?url=${encodeURIComponent(href)}`, {
      credentials: "include",
      signal,
    });

    if (!response.ok) {
      return null;
    }

    return response.json();
  }, []);
  const shouldPreview = useCallback((href: string) => {
    return new URL(href).origin !== window.location.origin;
  }, []);

  return (
    <Scribe
      externalLinkPreview={{
        resolve: resolveLinkPreview,
        shouldPreview,
        // Defaults to false. When enabled, standalone pastes become local Compact links.
        autoPreviewOnPaste: false,
      }}
    />
  );
}

By default, pasting a standalone external URL creates an ordinary link. Select it and choose Compact to shorten only its presentation, with no metadata request. A meaningful authored label is kept; a raw URL is shown as hostname/path without its query or fragment. Switching back to Plain restores the original label and exact destination. Set autoPreviewOnPaste: true only if standalone URL pastes should become local Compact links automatically. Pasting a URL over selected text always keeps an ordinary labeled link.

While editing, select a Plain, Compact, or Card link to open its contextual menu. From there you can edit or open the destination, or switch presentation. A Preview card is available only when the link has its own line and metadata is already stored or a resolver is configured. Refresh is available only for Preview cards with a resolver. Keyboard users can press Alt + F10 from a selected link to open the same menu and press Escape to return to the document.

The resolver runs only when a Preview card needs metadata: after explicit Card conversion, programmatic Card insertion, a Card destination edit, or Card refresh. Compact creation, editing, automatic paste, and document reopen make no metadata request. The resolver receives an AbortSignal, and can return pageTitle, description, siteName, faviconUrl, imageUrl, and fetchedAt. Use shouldPreview to keep app-owned or otherwise unsupported destinations on the ordinary-link path.

Preview nodes use sanitized raw HTML when content is serialized as Markdown, so their metadata and presentation survive Scribe's current Markdown round trip. A caller-owned externalEditor must register ExternalLinkPreview itself.

Experimental Table of Contents

Scribe can expose table-of-contents data without rendering a table-of-contents block inside the editable document. Enable the experimental API with enableTableOfContents, keep the latest items in your app state, and call scrollToTableOfContentsItem when a user selects an entry.

import { Scribe, ScribeRef, ScribeTableOfContentsItem } from "@clevertask/scribe";
import { useRef, useState } from "react";

function DocumentEditor() {
  const scribe = useRef<ScribeRef>(null);
  const [tableOfContentsItems, setTableOfContentsItems] = useState<ScribeTableOfContentsItem[]>([]);

  return (
    <>
      <Scribe
        ref={scribe}
        enableTableOfContents
        onTableOfContentsChange={setTableOfContentsItems}
      />

      {tableOfContentsItems.length > 0 ? (
        <nav aria-label="Table of contents">
          {tableOfContentsItems.map((item) => (
            <button
              key={item.id}
              type="button"
              onClick={() => scribe.current?.scrollToTableOfContentsItem(item)}
            >
              {item.textContent}
            </button>
          ))}
        </nav>
      ) : null}
    </>
  );
}

The table of contents currently tracks default TipTap heading nodes only. Each item includes the heading text, depth, document position, DOM node, and active/scrolled state. This API is marked experimental while we validate the contract in real document surfaces.

Math Expressions

Scribe's default UI is styled with Radix Themes components. Load @radix-ui/themes/styles.css once in your app alongside @clevertask/scribe/styles.css, and render Scribe somewhere inside a Radix <Theme>.

Scribe ships with @tiptap/extension-mathematics. The extension renders math when it receives math nodes in the HTML:

<span data-type="inline-math" data-latex="\alpha"></span>
<div data-type="block-math" data-latex="\sum_{i=1}^{n} x_i"></div>

Typing Delimiters (Input Rules)

When typing directly in the editor, the built-in input rules use:

Inline: $$\alpha$$
Block: $$$\sum_{i=1}^{n} x_i$$$

Markdown Delimiters

If you are parsing markdown with the Tiptap Markdown extension (not md2html), the tokenizer expects:

Inline: $\alpha$
Block: $$\sum_{i=1}^{n} x_i$$

If your content arrives as HTML (for example from a server), use the helper below to convert legacy delimiters into the HTML nodes that the math extension understands.

External Undo and Redo Ownership

Scribe enables its built-in undo and redo history by default. Set enableUndoRedo={false} when a different extension owns history, such as Tiptap Collaboration:

<Scribe enableUndoRedo={false} extensions={[Collaboration.configure({ document: ydoc })]} />

Scribe reads this option when it creates the editor. Remount Scribe to change it. If you pass an externalEditor, configure history on that editor instead. Do not add another ordinary history extension through extensions when collaboration owns undo and redo.

The createScribeEditor helper accepts the same enableUndoRedo option.

Props

| Prop | Type | Default | Description | | ------------------------- | ------------------------------------------------------------------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | content | string | undefined | The initial content of the editor. Controlled updates through this prop are deprecated; prefer ScribeRef.setContent for programmatic updates after mount. | | ariaLabel | string | Rich text editor | Sets the accessible name for the rich-text editing surface. Use a unique label when a page contains more than one editor. | | onContentChange | (content: ScribeOnChangeContents) => void; | undefined | A callback function triggered whenever the editor's content changes. It receives an object containing the current content in various formats (jsonContent, htmlContent, markdownContent). Internal table-of-contents metadata updates are ignored. | | editable | boolean | true | Controls whether the editor is editable. | | autoFocus | boolean | false | Controls whether the editor should automatically focus when mounted. | | extensions | Extension[] | undefined | You can set your own extensions for the text editor. For more information, check the tip tap extensions docs | | externalEditor | Editor | undefined | Uses a caller-owned Tiptap editor. The caller remains responsible for its extension and plugin lifecycle, including table resizing, and for destroying it. | | enableUndoRedo | boolean | true | Enables Scribe's built-in undo and redo history. Set it to false when another extension owns history. This creation-time option does not configure a caller-owned externalEditor. | | externalLinkPreview | Partial<ExternalLinkPreviewOptions> | undefined | Experimental. Opts into external-link metadata resolution and enhanced Compact/Card presentation. The consumer owns fetching and destination policy; automatic previews on paste default to disabled. | | editorProps | EditorProps | undefined | A tiptap-based prop to handle advanced use cases, you can read about it on their documentation | | showBarMenu | boolean | true | Determines whether to show the text editor top menu bar or not. This menu bar shows options to format the text | | placeholderText | string | Type "/" for commands... | Change the initial placeholder for your text editor | | editorContentStyle | React.CSSProperties | undefined | You can send a CSS object to add styles to the editor content container. Useful if you want to limit the editor's height. | | editorContentClassName | string | undefined | The same idea of editorContentStyle but with classes. | | mainContainerStyle | React.CSSProperties | undefined | You can send a CSS object to style the main editor container | | mainContainerClassName | string | undefined | The same idea of mainContainerStyle but with classes. | | onKeyDown | KeyboardEventHandler | undefined | A callback function that is triggered when a key is pressed within the editor. This allows you to handle custom keyboard shortcuts. For example, you can use this prop to implement a "send message" functionality when Ctrl + Enter is pressed. | | enableTableOfContents | boolean | false | Experimental. Enables the app-owned table-of-contents API for heading nodes. | | onTableOfContentsChange | (items: ScribeTableOfContentsItem[], isCreate?: boolean) => void | undefined | Experimental. Receives table-of-contents items whenever heading text, structure, or active/scrolled state changes. |

Helper Functions

md2html

export declare function md2html(md: string): string;

Convert markdown to html. Useful if you're rendering an AI-based response, or if you were storing content on markdown in your database and want to show it on the text editor. This function sanitizes the content to prevent XSS attacks.

Editable Scribe instances also use this conversion internally when you paste plain-text markdown into the editor.

Usage Example:

import { md2html, Scribe } from "@clevertask/scribe";
import { Flex, Heading } from "@radix-ui/themes";
import { Message, useChat } from "@ai-sdk/react";

const ChatMessages = () => {
  const { messages } = useChat({
    /* For more info, see https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-chat */
  });

  return messages.map((message) => (
    <Flex key={message.id} direction="column" mb="4">
      <Heading size="4">{`${message.role}: `}</Heading>
      <Scribe editable={false} showBarMenu={false} content={md2html(message.content)} />
    </Flex>
  ));
};

html2md

export declare function html2md(html: string): string;

Convert html to markdown. Useful if you want to send a text to an AI model by keeping the text format with markdown. This function sanitizes the content to prevent XSS attacks.

Usage Example:

import { html2md, Scribe } from "@clevertask/scribe";

const md = html2md("<h1>Hello world</h1>"); // Output: # Hello world

Note: The Scribe component already exposes a property called markdownContent when the onContentChange is used. In fact, the markdownContent is the output of the usage of the html2md function.


convertLegacyMathDelimiters

export declare function convertLegacyMathDelimiters(input: string): string;

Convert legacy math delimiters into the HTML nodes required by the mathematics extension. This is useful when you receive HTML from a server that contains legacy math like \(...\) or \[...\].

This helper is primarily for consumers upgrading from older Scribe versions who stored math expressions using the legacy formats. It aims to be accurate, but the previous format was ambiguous (no explicit $$ delimiters), so conversion is best-effort and not guaranteed in every case.

Supported legacy delimiters:

  • \(...\) for inline math
  • \[...\] for block math
  • (...) and [...] when the content looks like LaTeX (contains \, ^, or _)

Usage Example:

import { convertLegacyMathDelimiters, md2html, Scribe } from "@clevertask/scribe";

const html = md2html(convertLegacyMathDelimiters(rawMarkdown));

// OR

const html = convertLegacyMathDelimiters(htmlContent);

<Scribe editable={false} showBarMenu={false} content={html} />;

If you already receive HTML from the server, call convertLegacyMathDelimiters directly on that HTML before rendering.

Roadmap

We're constantly working to improve @clevertask/scribe. Here are some features we're planning to implement:

  • New default blocks/extensions: Such as image, video, callout, and table blocks
  • E2E tests: It will ensure this component's working as expected.

We're excited about these upcoming features and welcome any feedback or contributions from the community. If you have any suggestions or would like to contribute to any of these features, please open an issue or submit a pull request on our GitHub repository.

Release Process

Publishing is split into two explicit GitHub Actions after a change reaches main:

  1. Run Create Release Version from main and enter the version to release.
  2. Wait for it to commit the version, create the tag, and create the GitHub release.
  3. Run Publish Package from the immutable v<version> tag and choose the latest or next npm tag.
  4. Confirm the new version is available in the npm registry before updating consumers.

License

MIT

Credits

This project is built on top of the excellent BlockEditor repository by Sachin Chaurasiya. We extend our sincere gratitude for their work. <3