Skip to Content
Proofread

Proofread

Spell and grammar checking. The package has no backend of its own: you supply the checker (generateProofreadErrors) and where results are cached (createCache).

Packages

Package / entryContents
@stesura/proofreadproofreadPlugin, plugin keys, global flags
@stesura/proofread/commandsCommands
@stesura/proofread/helpersState readers and small utilities
@stesura/proofread/typesTypes only
@stesura/proofread/errorsProofreadRetryAfterError
@stesura/proofread/cache-idbcreateIdbProofreadCacheFactory — IndexedDB cache
@stesura/proofread/language-toolLANGUAGE_TOOL_SUPPORTED_LANGUAGES, PROOFREAD_MAX_CHUNK_SIZE
@stesura/proofread-reactToolbar group, panel, context menu, floating menu, status bar, UI bundle
@stesura/collab-adapter-reactcreateCollabProofreadCacheFactory, createCollabProofreadDictionaryFactory

language-tool imports nothing from the plugin, so server-side api routes can use it.

How it works

  • Each paragraph and heading is checked on its own. Its result is cached under its id together with a hash of its text, and it is only sent to your checker again when that hash changes.
  • Edits are debounced. The first run after mount, undo/redo and collaborative syncs run immediately.
  • Each error becomes a decoration. Moving the caret onto one (click or arrow keys) opens a floating suggestion menu; Tab moves focus into it.
  • “Ignore once” and “Ignore all” write a proofReadIgnore mark into the document, so they travel with the text through copy/paste and collaboration. Editing the ignored text removes the mark.
  • The user dictionary is the opposite: it belongs to the user, is applied when decorations are drawn, and never touches the document.
  • Proofreading on/off is one global preference shared by every editor on the page.

Setup

import { proofreadPlugin } from "@stesura/proofread"; import { createIdbProofreadCacheFactory } from "@stesura/proofread/cache-idb"; proofreadPlugin({ debounceTimeMS: 1000, generateProofreadErrors, // see "Implementing generateProofreadErrors" createCache: createIdbProofreadCacheFactory({ docId }), parentEditorId: undefined, // root editor; see "Sub-editors" });
OptionRequiredDescription
debounceTimeMSyesDelay after the last edit before a check runs.
generateProofreadErrorsyesYour checker. See below.
createCacheyes(field) => ProofreadCacheAdapter. See Cache adapters.
parentEditorIdyesThe parent editor’s id in a sub-editor, undefined in a root editor. See Sub-editors.
skipNodenoExcludes content. See Excluding content.
createDictionarynoEnables “Add to dictionary”. See User dictionary.
apiTimeoutMSnoHow long one call may take before it counts as failed. Default 30 000.

Then add the UI:

import { proofreadUIExtension } from "@stesura/proofread-react"; <StesuraEditor uiExtensions={[proofreadUIExtension]} /* ... */ />;

Cache adapters

IndexedDBcreateIdbProofreadCacheFactory({ docId }) from @stesura/proofread/cache-idb. Results survive reloads in the browser; nothing is shared. Keeps the 20 most recently opened documents (maxDocuments). Optional: dbName, onError.

Shared server cachecreateCollabProofreadCacheFactory({ docId, apiBasePath }) from @stesura/collab-adapter-react, backed by the collab backend’s /api/docs/:docId/proofread/:field routes. Each client loads the stored results once on mount, so reloads and other users don’t re-check what someone already checked. There is no live sync between clients. Pass the same headers/credentials as your CollabProvider.

Custom — implement ProofreadCacheAdapter (from @stesura/proofread/types):

interface ProofreadCacheAdapter { get(id: string): ProofreadCacheItem | undefined; set(id: string, value: ProofreadCacheItem): void; delete(id: string): void; entries(): Iterable<[string, ProofreadCacheItem]>; subscribe?(cb: (changedIds?: readonly string[]) => void): () => void; destroy?(): void; ready?: Promise<void>; }

Rules:

  • Synchronous. Reads happen while decorations are drawn. An async store keeps an in-memory copy and saves in the background.
  • One store per field, shared by every instance. createCache(field) is called by the root editor and again by each sub-editor (headers, footers, notes) with the same field. Sub-editors only repaint when subscribe fires, so set and delete must notify the subscribers of every instance, synchronously, with the changed ids.
  • ready — if the store loads asynchronously, expose a promise that resolves once loaded. The first run waits for it (at most 2 s). Resolve it on failure too, never reject.

A correct in-memory adapter:

import type { CreateProofreadCache, ProofreadCacheItem } from "@stesura/proofread/types"; type Store = { data: Map<string, ProofreadCacheItem>; subscribers: Set<(ids?: readonly string[]) => void>; }; // Call once per document. export const createMemoryCacheFactory = (): CreateProofreadCache => { const stores = new Map<string, Store>(); return (field) => { let store = stores.get(field); if (!store) stores.set(field, (store = { data: new Map(), subscribers: new Set() })); const { data, subscribers } = store; const notify = (id: string) => subscribers.forEach((cb) => cb([id])); return { get: (id) => data.get(id), set: (id, value) => { data.set(id, value); notify(id); }, delete: (id) => { data.delete(id); notify(id); }, entries: () => data.entries(), subscribe: (cb) => { subscribers.add(cb); return () => subscribers.delete(cb); }, }; }; };

Cache entries carry a format version, so after a package update changes the format, stored entries are treated as missing and re-checked. The version does not track your checker: upgrading it doesn’t re-check existing documents.

Excluding content

skipNode gets { node, pos, parent, index, $pos } and returns true to skip:

  • a skipped block and everything inside it is never checked or decorated;
  • a skipped text or inline node is left out of the text sent to the checker; the words around it are still checked.
proofreadPlugin({ // ... skipNode: ({ node, $pos }) => node.type.name === "code_block" || node.marks.some((m) => m.type.name === "code") || $pos.node(1)?.type.name === "table", });

It must give the same answer for the same document. Changing it is safe: the affected blocks are re-checked.

Track-changes deletions, footnote anchors and other inline atoms are always left out.

Sub-editors

Headers, footers and notes are edited in sub-editors that share the root editor’s cache. parentEditorId is required so the choice is explicit:

  • root editor: parentEditorId: undefined. Runs the checks and writes the cache.
  • sub-editor: parentEditorId: <root editor id>. Only draws decorations from the root’s cache and never calls your checker.

A sub-editor given the root form (no parentEditorId) gets no proofreading: the editor refuses the plugin and logs a warning.

Implementing generateProofreadErrors

import type { GenerateProofreadErrors } from "@stesura/proofread/types"; const generateProofreadErrors: GenerateProofreadErrors = async (text, language) => { const response = await fetch("/api/proofread", { method: "POST", body: JSON.stringify({ text, language }), }); if (!response.ok) throw new Error(`Proofread api failed (${response.status})`); // { matches: [{ offset, length, message, shortMessage?, type: { typeName }, replacements? }] } return response.json(); };

Input. text is several blocks of the same language joined by "\n", at most PROOFREAD_MAX_CHUNK_SIZE (60 000) characters; longer blocks are not checked. language is the blocks’ effective language — proofingLanguage, else the style’s, else the document’s — so it is always set.

Output. LanguageTool’s match shape. offset is into the text you received; the plugin maps it back to the document. typeName: "UnknownWord" is a spelling error, anything else a grammar warning, and only spelling errors can be added to the dictionary. An empty replacements value suggests deleting the text. The response is treated as untrusted: matches without a usable offset/length are dropped, and strings are capped.

Failure: throw, never return { matches: [] }. An empty result is cached as “checked, no errors” until the text changes, and in a shared cache for every collaborator. Throw on any non-2xx response, network error or unreadable body. When you throw, nothing is cached for those blocks, their old squiggles are removed, and they are retried automatically with backoff (5 s up to 60 s). The status bar and panel show “unavailable” with a Retry button until a check succeeds. Languages fail independently. A response without a matches array is treated as a failure too.

The one legitimate { matches: [] } without checking is a language your backend doesn’t support. For LanguageTool, LANGUAGE_TOOL_SUPPORTED_LANGUAGES lists what it can check.

Rate limits. To make the plugin wait a specific time, throw ProofreadRetryAfterError from @stesura/proofread/errors. parseRetryAfterMS from /helpers reads an HTTP Retry-After header. The plugin waits the longer of its own backoff and your value (capped at 5 minutes).

import { ProofreadRetryAfterError } from "@stesura/proofread/errors"; import { parseRetryAfterMS } from "@stesura/proofread/helpers"; if (response.status === 429) { const retryAfterMS = parseRetryAfterMS(response.headers.get("Retry-After")); throw retryAfterMS ? new ProofreadRetryAfterError(retryAfterMS) : new Error("rate limited"); }

User dictionary

“Add to dictionary” hides spelling errors for words the user accepts: names, brand terms, jargon.

  • The cache never sees it. Results are cached as returned by your checker, and the dictionary is applied when decorations are drawn. Adding or removing a word updates the squiggles instantly without calling the checker, and a shared cache stays valid for users with different dictionaries.
  • It belongs to the user, not the document. It is fetched by user identity, never by document, so a guest in a document never receives the owner organization’s dictionary. To hide a word for everyone in one document, use “Ignore all”.

Only spelling errors can be hidden. Matching is case-sensitive on the whole word. Words are stored per primary language ("en" covers en-US and en-GB); an entry with language: null applies to every language.

Enabling it

Pass createDictionary to every proofreadPlugin call, root and sub-editors alike. Without it the command returns false and the menu item is hidden.

import { createCollabProofreadDictionaryFactory } from "@stesura/collab-adapter-react"; proofreadPlugin({ // ... createDictionary: createCollabProofreadDictionaryFactory({ apiBasePath: "" }), });

The context menu then offers “Add to dictionary” on spelling errors. Unlike the ignore actions it stays enabled in read-only editors, since it writes to the user’s dictionary, not the document.

The built-in adapter and its endpoint

createCollabProofreadDictionaryFactory({ apiBasePath, endpoint? }) calls {apiBasePath}/api/proofread/dictionary (or endpoint). All instances on a page share one copy, so adding a word in the body also clears it in headers and notes; other tabs are updated through a BroadcastChannel. Writes are batched.

Your server implements two routes. Neither takes a document id: authorize by the session user only, never through document access checks.

  • GET{ entries: [{ text: string, language: string | null }] } — the user’s effective dictionary: their own entries plus those of every group they belong to.
  • POST { add: [{ text, language }], remove: [{ text, language }] } → any 2xx. Writes go to the user’s personal entries; group entries are managed however your app chooses.

Postgres schema

The reference shape (full version with RLS policies: apps/testbed/supabase/migrations/0002_proofread_dictionary.sql):

create table proofread_dictionary ( id uuid primary key default gen_random_uuid(), user_id uuid references users(id) on delete cascade, -- personal entry… group_id uuid, -- …or group entry text text not null, -- primary subtag only ("en", not "en-US"); null = every language language text check (language = lower(language) and position('-' in language) = 0), created_at timestamptz not null default now(), check ((user_id is null) <> (group_id is null)) -- exactly one owner ); -- adding a word twice is a no-op create unique index proofread_dictionary_unique_entry on proofread_dictionary (coalesce(user_id, group_id), text, coalesce(language, '')); -- however your app models group membership create table group_members ( group_id uuid not null, user_id uuid not null references users(id) on delete cascade, primary key (group_id, user_id) );

The GET query is the whole security model: where user_id = :me or group_id in (select group_id from group_members where user_id = :me). On Supabase, write that as RLS policies and the handler just passes through.

Custom adapters

ProofreadDictionaryAdapter (from @stesura/proofread/types) is synchronous like the cache adapter: keep an in-memory copy and save in the background. language is a primary subtag or null, and has must also match null entries. subscribe should fire on any change, including writes made through another instance on the same page.

interface ProofreadDictionaryAdapter { has(word: string, language?: string | null): boolean; add(word: string, language?: string | null): void; remove(word: string, language?: string | null): void; subscribe?(cb: () => void): () => void; destroy?(): void; }

Commands

From @stesura/proofread/commands. The commands that act on one error take its decoration: use getProofreadDecoInSelection(state), or findProofreadDecorationByKey(state, key) for a decoration captured earlier (the document may have changed since).

CommandSignatureDescription
toggleProofreadCommandTurn proofreading on/off for every editor on the page.
retryProofreadCommandRe-check the whole document now, skipping the debounce and the failure backoff. false while proofreading is off.
setLanguage(language: string | null) => CommandSet the selected blocks’ proofingLanguage; null makes them inherit again. From core, re-exported here.
onProofreadReplace(value: string, decor: ProofreadDecoration) => CommandReplace the flagged text with value (keeping its formatting) and select it. "" deletes the text.
onProofreadIgnore(decor: Decoration) => CommandIgnore this occurrence.
onProofreadIgnoreAll(decor: Decoration) => CommandIgnore every whole-word, case-sensitive occurrence in the document.
onProofreadAddToDictionary(decor: Decoration) => CommandAdd the word to the user’s dictionary under the block’s language. false without createDictionary.

Language inheritance and setDocLanguage are covered in Commands: Specialized.

State and helpers

From @stesura/proofread:

ExportDescription
getProofreadEnabled() / setProofreadEnabled(value)The global on/off preference. Setting it updates every editor.
getProofreadLoading() / getProofreadLoadingFor(editorId)A check is in progress — in any editor / in this root editor.
getProofreadApiFailed() / getProofreadApiFailedFor(editorId)The checker is failing — in any editor / in this root editor. Clears on the next successful check.
subscribeSubEditorProofreadDecorVersion(cb), getSubEditorProofreadDecorVersion()A counter bumped whenever a sub-editor repaints its errors, for UI that lists errors from every editor.
proofreadPluginKey, proofreadSubscriberPluginKeyPlugin keys for root and sub-editors. Prefer getProofreadPluginKey(state).

From @stesura/proofread/helpers:

HelperDescription
getProofreadPluginKey(state)The plugin key for this editor, root or sub-editor.
getProofreadDecor(state)Every proofread decoration, as an array.
getProofreadDecoInSelection(state)The decoration at the selection, if any.
findProofreadDecorationByKey(state, key)The current version of a decoration (decor.spec.key), or undefined once the error is gone.
getProofreadSuggestions(deco)The decoration’s suggested replacements.
getProofreadDictionary(state)The configured dictionary adapter, if any.
primaryLanguageSubtag(language)"en-US""en".
parseRetryAfterMS(header)HTTP Retry-After → milliseconds.
retryAfterMSOf(err)The delay carried by a ProofreadRetryAfterError.

A decoration’s spec.error holds the message, type and suggestions. Its from/to are relative to the paragraph; use the decoration’s own from/to for document positions.

React components

From @stesura/proofread-react:

ExportDescription
proofreadUIExtensionEverything below, for uiExtensions. Recommended.
ProofreadPanelRight panel listing every error, including headers, footers and notes, filterable by spelling/grammar.
ProofingGroupReview-tab group: language picker and on/off toggle.
ProofreadToggleToolbar on/off toggle.
LanguageSelectorLanguage picker for the selection. languages overrides the list offered.
ProofreadButtonLarge button opening/closing the panel, for custom toolbars.
ProofreadContextMenuContext-menu submenu: suggestions, ignore once/all, add to dictionary.
ProofreadFloatingMenuControllerSuggestion menu at the caret: suggestions and ignore once.
ProofreadToggleStatusBarStatus-bar indicator (on / off / unavailable) with toggle, retry and a panel shortcut.

Next steps

Last updated on