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, commands, helpers, errors, types |
@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,
parseRetryAfterMS,
ProofreadApiError,
ProofreadRetryAfterError,
} 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. |
maxConcurrentRequests | no | How many calls the editor keeps in flight. Default 2. See Concurrency. |
chunkSize | no | Characters per call; longer blocks are not checked. Default 40 000. See Chunk size. |
minChunkSize | no | Floor for halving the chunk size after timeouts. Default 10 000. See Chunk size. |
maxRetries | no | Failed runs retried on the backoff ladder before giving up. Default Infinity. See below. |
Then add the UI:
import { proofreadUIExtension } from "@stesura/proofread-react";
<StesuraEditor uiExtensions={[proofreadUIExtension]} /* ... */ />;To add content at the bottom of the status-bar proofread menu (e.g. attribution for your checking service), build the extension once at module level with createProofreadUIExtension:
import { DropdownMenuItem } from "@stesura/editor-react-ui";
import { createProofreadUIExtension } from "@stesura/proofread-react";
const proofreadUI = createProofreadUIExtension({
statusBarMenuFooter: (
<DropdownMenuItem render={<a href="https://languagetool.org" target="_blank" rel="noreferrer" />}>
Powered by LanguageTool
</DropdownMenuItem>
),
});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:
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,
GenerateProofreadErrors,
} from "@stesura/proofread";
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
const generateProofreadErrors: GenerateProofreadErrors = async (text, language) => {
const response = await fetch("/api/proofread", {
method: "POST",
body: JSON.stringify({ text, language }),
});
if (!response.ok) throw new ProofreadApiError(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 chunkSize (default 40 000) characters; a block longer than that is not checked at all. 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 that call’s blocks, their old squiggles are removed, and they are retried automatically with backoff (5 s up to 60 s). Calls that answered keep their results, so a retry only re-sends what failed. The status bar and panel show “unavailable” with a Retry button until a check succeeds. A response without a matches array is treated as a failure too. maxRetries caps the retries: once spent, nothing is sent until the user edits or presses Retry.
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. parseRetryAfterMS reads an HTTP Retry-After header. The plugin waits the longer of its own backoff and your value (capped at 5 minutes).
if (response.status === 429) {
const retryAfterMS = parseRetryAfterMS(response.headers.get("Retry-After"));
throw retryAfterMS ? new ProofreadRetryAfterError(retryAfterMS) : new ProofreadApiError(429);
}Timeouts and outages. Throw ProofreadApiError(status) for every other failed response. A timeout (408, 504, or the plugin’s own apiTimeoutMS) means the call was too slow, not that the checker is down: the chunk size halves (see Chunk size), the call is re-sent in halves at once and the remaining calls go ahead. A call that cannot shrink fails on a timeout and is retried on the backoff; two such timeouts in a row stop that language’s remaining calls until the retry. Any other error stops them at once, since they would hit the same outage. A plain Error counts as an outage.
Concurrency
Opening a long document for the first time is one call per 40 000 characters: a 350-page document is about 30 calls, each a few seconds against LanguageTool. The editor keeps maxConcurrentRequests of them in flight (default 2) and queues the rest in document order, so the top of the document is checked first. What the user edits meanwhile goes ahead of the queue. The status bar shows how many paragraphs are left.
The cap protects two things. Over HTTP/1.1 the browser opens at most six connections per host, so an unbounded burst holds them all and every other request to your origin waits. And LanguageTool checks a few requests at a time per core: thirty at once make each slower, not the whole faster. Keep 2 for a shared instance; raise it for a dedicated one with spare cores. 1 checks one call at a time; Infinity sends everything at once.
Each call is written to the cache and painted as soon as it answers, so the top of a long document shows its squiggles while the rest is still being checked.
Chunk size
Each call carries up to chunkSize characters (default 40 000). A checker’s time is linear in the text, so the size sets how long one call takes and how much a failure leaves unchecked, never the total work. Larger chunks mean fewer round trips and more cross-sentence context at chunk edges; smaller ones finish under your route’s and LanguageTool’s timeouts.
The worst case is dense text: long sentences and many unknown tokens, as in contracts and templated documents. On one LanguageTool instance, 40 000 characters of a credit agreement took 9.2–9.4 seconds (about 4 300 characters per second) and of a novel 2 seconds. When a call times out, the plugin halves the chunk size for the rest of the session, down to minChunkSize (default 10 000), re-sends that call in halves and re-splits the calls still waiting. It never grows back, and a timeout never counts as an outage unless the call cannot shrink. Only timeouts halve; a 429 or a 500 does not. chunkSize also caps the longest block that gets checked, and stays the cap after halving: a block between the halved size and chunkSize is sent on its own.
Your route’s timeout must exceed chunkSize ÷ your checker’s throughput for your densest documents: 40 000 characters at 4 300 per second is 9.3 seconds, so give the route 15.
Rule selection
Which rules run is a request parameter, so it is yours to choose in generateProofreadErrors (or the route behind it). For LanguageTool: disabledRules, disabledCategories, enabledOnly with enabledRules/enabledCategories, and level.
const params = new URLSearchParams({
language,
text,
// Contract templates: [Borrower] fields trip the bracket and whitespace rules.
disabledRules: "EN_UNPAIRED_BRACKETS,COMMA_PARENTHESIS_WHITESPACE",
});Filter here, at ingest. Filtering when drawing would cache the noise, and a shared cache would carry it to every collaborator.
Hosting LanguageTool
One check occupies one core for its whole duration. A first open of a 350-page contract is about 900 000 characters: some 210 core-seconds at 4 300 characters per second. That cost is per document, not per user: with a shared cache (createCollabProofreadCacheFactory) the second open makes no calls. Size cores for the first opens you expect at the same moment: each keeps maxConcurrentRequests calls in flight, so two cores per simultaneous first open, plus one for anything else on the box.
With the erikvl87/languagetool image, these go under environment:
| Setting | Suggested | Why |
|---|---|---|
langtool_maxCheckThreads | cores you give LanguageTool | Checks run in parallel up to this; more only share the same cores. |
langtool_maxWorkQueueSize | 2 × threads | Past it, requests get an immediate 503 (“too many parallel requests”) instead of waiting minutes behind a first open. Map that to a 429 with Retry-After in your route and the plugin waits it out. |
langtool_maxCheckTimeMillis | just above your route’s timeout | A request your route gave up on stops using its core. |
langtool_cacheSize | a few thousand sentences | Contracts repeat boilerplate. Memory grows with it. |
langtool_maxSpellingSuggestions | 5 | Not what the name suggests: how many misspellings per sentence get suggestions. Generating them is most of the cost of contract text, and the limit halved it in our measurements. Each misspelling past it gets a placeholder ((suggestion limit reached)) as its only replacement, which the editor would offer as a fix: strip it in your route. |
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 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
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. |
getProofreadBacklogFor(editorId), subscribeProofreadBacklog(cb) | Paragraphs the root editor’s queued and in-flight calls carry; 0 when idle. See Concurrency. |
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). |
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. |
isProofreadTimeoutError(err) | Whether err is a ProofreadApiError for a timeout (408, 504). |
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, paragraphs left while checking) 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.