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 / entry | Contents |
|---|---|
@stesura/proofread | proofreadPlugin, plugin keys, global flags |
@stesura/proofread/commands | Commands |
@stesura/proofread/helpers | State readers and small utilities |
@stesura/proofread/types | Types only |
@stesura/proofread/errors | ProofreadRetryAfterError |
@stesura/proofread/cache-idb | createIdbProofreadCacheFactory — IndexedDB cache |
@stesura/proofread/language-tool | LANGUAGE_TOOL_SUPPORTED_LANGUAGES, PROOFREAD_MAX_CHUNK_SIZE |
@stesura/proofread-react | Toolbar group, panel, context menu, floating menu, status bar, UI bundle |
@stesura/collab-adapter-react | createCollabProofreadCacheFactory, 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
proofReadIgnoremark 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"
});| Option | Required | Description |
|---|---|---|
debounceTimeMS | yes | Delay after the last edit before a check runs. |
generateProofreadErrors | yes | Your checker. See below. |
createCache | yes | (field) => ProofreadCacheAdapter. See Cache adapters. |
parentEditorId | yes | The parent editor’s id in a sub-editor, undefined in a root editor. See Sub-editors. |
skipNode | no | Excludes content. See Excluding content. |
createDictionary | no | Enables “Add to dictionary”. See User dictionary. |
apiTimeoutMS | no | How 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
IndexedDB — createIdbProofreadCacheFactory({ 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 cache — createCollabProofreadCacheFactory({ 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 samefield. Sub-editors only repaint whensubscribefires, sosetanddeletemust 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).
| Command | Signature | Description |
|---|---|---|
toggleProofread | Command | Turn proofreading on/off for every editor on the page. |
retryProofread | Command | Re-check the whole document now, skipping the debounce and the failure backoff. false while proofreading is off. |
setLanguage | (language: string | null) => Command | Set the selected blocks’ proofingLanguage; null makes them inherit again. From core, re-exported here. |
onProofreadReplace | (value: string, decor: ProofreadDecoration) => Command | Replace the flagged text with value (keeping its formatting) and select it. "" deletes the text. |
onProofreadIgnore | (decor: Decoration) => Command | Ignore this occurrence. |
onProofreadIgnoreAll | (decor: Decoration) => Command | Ignore every whole-word, case-sensitive occurrence in the document. |
onProofreadAddToDictionary | (decor: Decoration) => Command | Add 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:
| Export | Description |
|---|---|
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, proofreadSubscriberPluginKey | Plugin keys for root and sub-editors. Prefer getProofreadPluginKey(state). |
From @stesura/proofread/helpers:
| Helper | Description |
|---|---|
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:
| Export | Description |
|---|---|
proofreadUIExtension | Everything below, for uiExtensions. Recommended. |
ProofreadPanel | Right panel listing every error, including headers, footers and notes, filterable by spelling/grammar. |
ProofingGroup | Review-tab group: language picker and on/off toggle. |
ProofreadToggle | Toolbar on/off toggle. |
LanguageSelector | Language picker for the selection. languages overrides the list offered. |
ProofreadButton | Large button opening/closing the panel, for custom toolbars. |
ProofreadContextMenu | Context-menu submenu: suggestions, ignore once/all, add to dictionary. |
ProofreadFloatingMenuController | Suggestion menu at the caret: suggestions and ignore once. |
ProofreadToggleStatusBar | Status-bar indicator (on / off / unavailable) with toggle, retry and a panel shortcut. |
Next steps
- Commands: Specialized — the commands and language resolution in detail.
- Plugins — plugin composition order.
- UI Extensions — how
uiExtensionsbundles work.