Proofing
On this page
@better-rich-text/plugin-proofing adds spelling, grammar and style checking. Native browser spellcheck works with no setup. Providers add checking through a local word list, an on-device AI model, your own HTTP API, LanguageTool or a cloud AI model reached through your server.
import { createEditor } from '@better-rich-text/engine';
import { browserProvider, createProofingPlugin, languageToolProvider } from '@better-rich-text/plugin-proofing';
const proofing = createProofingPlugin({
providers: [browserProvider(), languageToolProvider({ url: '/api/languagetool' })],
language: 'en-GB',
});
const editor = createEditor({ plugins: [/* ... */ proofing] });Results are painted with the CSS Custom Highlight API as brt-spelling, brt-grammar and brt-style (style and terminology), so the content DOM is never changed. Clicking a flagged word opens suggestions. Add proofing to the toolbar layout for an on/off toggle and issue count.
How checking works
After an edit, changed textblocks are checked once typing pauses (
debounceMs, default 600 ms). Unchanged blocks are never resent.A request whose blocks all change again is aborted. Results for a block whose text changed while the request was in flight are discarded.
Results move with edits elsewhere in the block and disappear as soon as the flagged text, or the text right next to it, changes.
Code blocks, inline code, URLs and email addresses are replaced with U+FFFC before sending, so offsets stay aligned and that text never leaves the editor. Inline atoms are U+FFFC too.
Requests are capped (
limits): 40 blocks and 12,000 characters per request; blocks over 10,000 characters are skipped.A failing provider is shown as
failedin the status and never blocks typing. Failed blocks are not resent until they change or a check is requested withproofing.check { force: true }. Remote providers are skipped while the browser is offline.
Providers
Pass any mix in providers. Each one sees only blocks in its languages (if set). With no providers option the plugin uses [browserProvider()].
Browser spellcheck
browserProvider()Keeps the editing surface's spellcheck attribute on while proofing is enabled, and sets lang from the proofing language when the host has not set one. Nothing is sent anywhere. Browsers do not expose native suggestions to scripts, so this provider reports no issues of its own.
If browserProvider() is not in the list, native spellcheck is turned off so users do not see two sets of underlines. Destroying the view restores the original attributes.
Dictionary
dictionaryProvider({ words: await loadWordList(), suggestions: 5 })A local word-list checker for offline use and tests. Lowercase entries accept any capitalisation; capitalised entries such as names must match exactly or in capitals. Short words in capitals are treated as acronyms. Suggestions use edit distance (up to 2).
Custom HTTP endpoint
By default the provider POSTs JSON and expects the standard response shape:
httpProvider({ endpoint: '/api/proof', headers: () => ({ 'X-CSRF-Token': csrfToken() }) })// request
{ "blocks": [{ "id": "n12", "text": "Teh cat sat.", "language": "en-GB" }] }
// response
{ "issues": [{ "blockId": "n12", "start": 0, "end": 3, "kind": "spelling",
"message": "Possible spelling mistake", "replacements": ["The"], "ruleId": "TYPO" }] }Offsets are UTF-16 offsets into text; end is exclusive. kind is spelling, grammar, style or terminology. Malformed issues are dropped one by one.
Use mapRequest and mapResponse for any other service:
httpProvider({
id: 'acme',
label: 'Acme Proof',
endpoint: '/api/acme-proof',
mapRequest: (blocks) => ({ documents: blocks.map((b) => ({ key: b.id, body: b.text, locale: b.language })) }),
mapResponse: (response) =>
(response as AcmeResponse).findings.map((f) => ({
blockId: f.key,
start: f.offset,
end: f.offset + f.length,
kind: f.category === 'spelling' ? 'spelling' : 'grammar',
message: f.explanation,
replacements: f.fixes,
})),
});Error messages shown to users are fixed strings (The proofing service returned HTTP 503); response bodies are never displayed. Requests time out after timeoutMs (default 15 s).
LanguageTool
languageToolProvider({ url: 'https://languagetool.example.org' }) // self-hosted
languageToolProvider({ url: '/api/languagetool', level: 'picky' }) // through your proxyCalls /v2/check. Blocks with the same language are joined into one request and the matches are mapped back to blocks. issueType and category map to the proofing kinds (misspelling → spelling, style-like types → style, the rest → grammar). Supports language (fixed or auto), motherTongue, level, enabledRules, disabledRules, enabledCategories, disabledCategories and preferredVariants.
For LanguageTool Premium, do not put username and apiKey in browser code. Point url at a proxy on your server that adds them. mapLanguageToolResponse is exported for LanguageTool-compatible services behind httpProvider.
On-device AI
onDeviceProvider() checks spelling and grammar using a prepared local model. It is optional: installing the proofing plugin alone still uses native browser spellcheck. Local checks work offline once the browser's model is available. No cloud provider is selected when a local check fails.
Start disabled and let the user enable on-device checking explicitly:
<button id="local-proofing" disabled>Enable on-device proofreading</button>
<progress id="proofing-download" max="1" hidden aria-label="Model download"></progress>
<p id="proofing-status" role="status"></p>
<div id="editor"></div>import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { createOnDeviceAiModel } from '@better-rich-text/plugin-ai';
import { createProofingPlugin, onDeviceProvider } from '@better-rich-text/plugin-proofing';
const localModel = createOnDeviceAiModel();
const editor = createRichTextEditor('#editor', {
plugins: [createProofingPlugin({
providers: [onDeviceProvider({ model: localModel })],
language: 'en-AU',
enabled: false,
checkOnLoad: false,
})],
// The explicit button below is the only enable control in this example.
toolbar: standardPreset.toolbar,
});
const toggle = document.querySelector<HTMLButtonElement>('#local-proofing')!;
const progress = document.querySelector<HTMLProgressElement>('#proofing-download')!;
const status = document.querySelector<HTMLElement>('#proofing-status')!;
let enabled = false;
toggle.onclick = async () => {
if (enabled) {
editor.editor.execute('proofing.setEnabled', { enabled: false });
enabled = false;
toggle.textContent = 'Enable on-device proofreading';
status.textContent = 'On-device proofreading is off.';
return;
}
toggle.disabled = true;
progress.hidden = false;
progress.removeAttribute('value');
status.textContent = 'Preparing on-device proofreading…';
try {
// Call prepare directly from the click, before awaiting anything else.
await localModel.prepare({
onDownloadProgress(value) {
progress.value = value;
status.textContent = `Downloading model: ${Math.round(value * 100)}%`;
},
});
editor.editor.execute('proofing.setEnabled', { enabled: true });
enabled = true;
toggle.textContent = 'Disable on-device proofreading';
status.textContent = 'On-device proofreading is on. Review suggestions before applying them.';
} catch {
status.textContent = 'Could not prepare on-device proofreading. Check browser support and try again.';
} finally {
progress.hidden = true;
toggle.disabled = false;
}
};
async function checkAvailability() {
try {
const state = await localModel.availability();
toggle.disabled = state === 'unavailable';
status.textContent = state === 'unavailable'
? 'On-device AI is unavailable in this browser or on this device.'
: 'Enable on-device proofreading to start checking. A model download may be required.';
} catch {
status.textContent = 'Could not check on-device AI availability.';
}
}
void checkAvailability();availability() never downloads anything. prepare() requires a user gesture when the model needs a download; it also accepts an AbortSignal to cancel preparation. Neither checking nor a failed request starts a download automatically. See on-device AI for availability states and browser requirements.
Keep every enable control behind the same opt-in. The example omits the generic proofing toolbar toggle so it cannot enable a previously downloaded model before the user chooses on-device processing. Hosts can expose that toggle after consent. Disabling proofing aborts pending checks and hides its highlights.
The provider uses processing: 'local', so the controller continues to check while offline. Checks run one textblock at a time, with inference serialised across concurrent requests. The default kinds are ['spelling', 'grammar']; add style or terminology only when those suggestions are wanted. languages defaults to ['en'], which includes regional tags such as en-AU. For other languages, configure both the model's inputLanguages and the provider's languages.
Corrections are requested through completeStructured() with a JSON schema passed to Chrome's structured output API. Each correction includes the block id, UTF-16 offsets, original text, kind, message and replacements. The schema constrains the response format; it does not guarantee that a spelling or grammar suggestion is correct. Replies are still parsed and checked against the source. Unknown blocks, invalid ranges, mismatched original text and excluded content are rejected. The default locate: 'strict' drops wrong offsets; locate: 'unique' allows re-anchoring only when the original text appears exactly once.
The personal dictionary, ignores, stale-result checks and undoable suggestions work as they do for the other providers. The provider accepts any local model with the completeStructured(prompt, schema, { signal }) method; the proofing package has no dependency on the AI plugin. Installing cloud providers remains a separate, explicit application choice. Do not add a remote provider to the local configuration as a fallback.
AI
aiProvider({
complete: async (prompt, signal) => {
const res = await fetch('/api/proofing/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal,
});
if (!res.ok) throw new Error('AI proofing failed');
return (await res.json()).text;
},
instructions: 'Use British spelling. Prefer "sign in" over "log in".',
})The plugin builds a prompt that asks for strict JSON with exact offsets and treats document text as data, not instructions. The reply is parsed and validated with the model package's runtime schemas, and each issue is checked against the text that was sent: an issue whose original is not exactly text.slice(start, end) is dropped. With locate: 'unique', such an issue is moved to the original text instead when that text occurs exactly once in the block.
Model credentials stay on your server. A minimal endpoint:
// POST /api/proofing/ai: runs on your server, never in the page.
export async function POST(request: Request): Promise<Response> {
const user = await requireSignedInUser(request); // your auth
await rateLimit(user.id, 'proofing'); // your limits
const { prompt } = (await request.json()) as { prompt?: unknown };
if (typeof prompt !== 'string' || prompt.length > 60_000) return new Response('Bad request', { status: 400 });
// Call your model provider with its server-side SDK. The key comes from the
// server environment and is never sent to the browser.
const text = await callModel({ apiKey: process.env.MODEL_API_KEY!, prompt, maxTokens: 2048, signal: request.signal });
return Response.json({ text });
}An endpoint that forwards any prompt lets signed-in users run arbitrary prompts on your account. For a tighter contract, send blocks instead: use httpProvider in the browser, and on the server build the prompt with buildProofingPrompt(blocks, kinds), call the model, then return { issues: validateAiIssues(extractJson(reply), blocks, { providerId: 'ai', kinds }) }.
Options
| Option | Default | Meaning |
|---|---|---|
providers |
[browserProvider()] |
Checkers to run. |
language |
en-US |
Language for blocks without a lang attribute. |
debounceMs |
600 |
Pause after typing before changed blocks are checked. |
enabled |
true |
Start with checking on. |
checkOnLoad |
true |
Check existing content when the editor starts. Set false to send nothing until the first edit or an explicit proofing.check. |
ignore |
{} |
nodes (node types to skip), marks (default ['code']), patterns (extra regular expressions to exclude). |
dictionary |
in memory | DictionaryStore for the personal dictionary and "ignore all" list. |
limits |
see above | maxBlocksPerRequest, maxCharsPerRequest, maxBlockChars, maxIssuesPerBlock. |
layer |
EditorUi layer |
Element that receives the suggestions popover. |
Commands
| Command | Input |
|---|---|
proofing.check |
{ force?: boolean }. Check now; force resends everything and retries failures. |
proofing.applySuggestion |
{ issueId, replacement }. One undoable edit. Fails with stale-analysis if the text changed. |
proofing.ignore |
{ issueId }. This occurrence only. |
proofing.ignoreAll |
{ issueId }. Every occurrence of the same text. Persisted. |
proofing.addToDictionary |
{ word }. Persisted. |
proofing.removeFromDictionary |
{ word } |
proofing.nextIssue |
{ backward?: boolean }. Selects the next issue, wrapping around. |
proofing.setEnabled |
{ enabled }. Also switches native spellcheck. |
proofing.setLanguage |
{ language }. BCP 47 tag or auto; rechecks everything. |
Keys: F8 and Shift-F8 move between issues; Mod-Alt-; opens suggestions at the caret.
For custom interfaces, proofingController(editor) exposes issues(), status(), subscribe() and checkNow(), and openProofingSuggestions(view, issueId?) opens the popover.
Hooks
// Redact text before it is sent. Keep each block's length; drop a block to skip it.
editor.hooks.addFilter('proofing.request', (blocks) =>
blocks.map((b) => ({ ...b, text: b.text.replace(/\b\d{16}\b/g, (m) => '#'.repeat(m.length)) })),
);
// Filter or adjust results before they are stored.
editor.hooks.addFilter('proofing.issues', (issues) => issues.filter((i) => i.ruleId !== 'WHITESPACE_RULE'));
editor.hooks.addAction('proofing.checked', ({ blockIds, staleBlockIds, issues }) => { /* ... */ });
editor.hooks.addAction('proofing.failed', ({ provider, error }) => reportToMonitoring(provider, error));Dictionary storage
createProofingPlugin({ dictionary: localStorageDictionaryStore(`brt:dictionary:${userId}`) });A DictionaryStore has load() and save(data), either of which may be async, with data as { words: string[]; ignored: string[] }. Scope the key or your server-backed store to the signed-in user or tenant. Personal dictionary words hide spelling issues from every provider; ignored texts hide issues of any kind.
Autocorrect
createAutocorrectPlugin from @better-rich-text/plugin-typography (or the /autocorrect subpath) fixes text as you type. It is opt-in and not part of the standard preset.
import { createAutocorrectPlugin } from '@better-rich-text/plugin-typography/autocorrect';
const autocorrect = createAutocorrectPlugin({
replacements: { brt: 'Better Rich Text' },
languages: { fr: { stp: "s'il te plaît" } },
abbreviations: ['approx.', 'ref.'],
});autocorrectPlugin is the same with default options.
A word is corrected when you type a space after it, so a word you are still typing, a URL or an email address is never changed halfway. The rules:
Capitalise the first word of a sentence (after
.,!or?and a space), of a block, and after a line break.English only:
i,i'm,i've,i'llandi'dbecomeI….TWo INitial CApitals:
THe→The. Plurals of acronyms (IDs,PCs) and units such asMHzare left alone.Replacement lists:
teh→the, keeping the case you typed (Teh→The,TEH→THE).
A sentence does not end at an abbreviation (e.g., etc., Mr., each language's own list), an initial (J.) or a number (3., 3.5).
Never changed
Code blocks and inline code.
Linked text.
Anything that is not a plain word: URLs (
https://…,www.…), domains (example.com), email addresses, file paths, numbers.
Languages
The language comes from the block's lang attribute (or an ancestor's), then the document's language metadata, then the language option (default en). Built-in lists cover en, fr, de and es and are exported as autocorrectLanguages. Your words are merged on top, in this order: built-in list, the typography autocorrect map, replacements, languages[primary] (such as fr), then languages[tag] (such as en-gb).
Undo
Each correction is its own undo step. Straight after one, Backspace or Mod-Z restores what you typed, and that word is then left alone for the rest of the session. Any other edit or caret move ends the chance to undo with Backspace; after that, Backspace deletes as usual.
Options
| Option | Default | Meaning |
|---|---|---|
enabled |
true |
Start with autocorrect on. |
capitalizeSentences |
true |
Capitalise sentence, block and line starts. |
capitalizeI |
true |
English i → I. |
fixTwoCapitals |
true |
THe → The. |
twoCapitalsExceptions |
[] |
Words, matched exactly, the two-capitals fix leaves alone. |
defaultReplacements |
true |
Use the built-in lists. |
replacements |
{} |
Replacements for every language. |
languages |
{} |
Replacements per language tag. |
abbreviations |
[] |
Extra abbreviations that do not end a sentence. |
language |
en |
Language when the content sets none. |
Commands
| Command | Input |
|---|---|
autocorrect.setEnabled |
{ enabled }. active reports the current state. |
autocorrect.undo |
none. Restores the last correction; fails once anything else has happened. Bound to Backspace. |
Hooks
// Add or remove words; keys are lowercase.
editor.hooks.addFilter('autocorrect.replacements', (list, { language }) => (language === 'en' ? { ...list, pls: 'please' } : list));
editor.hooks.addAction('autocorrect.applied', ({ original, replacement, kinds }) => { /* ... */ });kinds lists what changed the word: replacement, pronoun, twoCapitals and sentence.
Typography plugin
The typography plugin's autocorrect word map keeps working on its own. When both plugins are installed, the autocorrect plugin takes over: it merges that map into its lists and the typography rule stands down, so autocorrect.setEnabled turns both off.