Check text blocks with your own rules, underline findings by severity, and fix them from a popover or a list.
F8 and Shift+F8 in the kit.import { createEditor } from 'platejs/react';
import { LintKit } from '@/components/editor/lint';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...LintKit,
],
});import { createEditor } from 'platejs/react';
import { LintKit } from '@/components/editor/lint';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...LintKit,
],
});LintKit configures BaseLintPlugin with two example rules, fillerWords and
commaSpacing, underline classes per severity, the F8 and Shift+F8 keys and
LintPopover. The same file exports useLintFindings and useLintFindingAt
for your own lint UI.
'use client';
import { type Element, PLUGINS } from 'platejs';
import { BaseLintPlugin, type LintRule } from 'platejs/lint';
import {
type EditableSiblingProps,
toReactPlugin,
useEditor,
useEditorFocused,
useEditorReadOnly,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
const isProse = (block: Element) => block.type !== PLUGINS.codeBlock;
export const fillerWords: LintRule = {
id: 'style/filler-words',
severity: 'warning',
check: (text, { block }) => {
if (!isProse(block)) return [];
let previousEnd = 0;
return Array.from(
text.matchAll(/\b(?:actually|basically|really|very)\b/gi),
(match) => {
const after = match.index + match[0].length;
const spaceBefore =
match.index > previousEnd && text[match.index - 1] === ' ';
const start = spaceBefore ? match.index - 1 : match.index;
const end = !spaceBefore && text[after] === ' ' ? after + 1 : after;
previousEnd = end;
return {
start,
end,
message: `Cut the filler word "${match[0]}".`,
fixes: [''],
};
}
);
},
};
export const commaSpacing: LintRule = {
id: 'style/comma-spacing',
severity: 'error',
check: (text, { block }) =>
isProse(block)
? Array.from(text.matchAll(/ , ?/g), (match) => ({
start: match.index,
end: match.index + match[0].length,
message: 'Remove the space before commas.',
fixes: [', '],
}))
: [],
};
/** Findings in document order, read again whenever the list changes. */
export function useLintFindings() {
const lint = useEditor().plugin(BaseLintPlugin);
return React.useSyncExternalStore(
lint.store.subscribe,
lint.api.findings,
lint.api.findings
);
}
/** The finding under the selection focus, or null. */
export function useLintFindingAt() {
const editor = useEditor();
const lint = editor.plugin(BaseLintPlugin);
const subscribe = React.useCallback(
(onChange: () => void) => {
const offStore = lint.store.subscribe(onChange);
const offCommit = editor.subscribeCommit(onChange);
return () => {
offStore();
offCommit();
};
},
[editor, lint]
);
return React.useSyncExternalStore(subscribe, lint.api.at, lint.api.at);
}
export function LintPopover({ editableRef }: EditableSiblingProps) {
const editor = useEditor();
const lint = editor.plugin(BaseLintPlugin);
const finding = useLintFindingAt();
const focused = useEditorFocused();
const readOnly = useEditorReadOnly();
const id = finding?.id;
// A new anchor per finding moves the popover; it sits under the finding's
// first painted segment, or under the caret before the paint catches up.
const anchor = React.useMemo(
() =>
id === undefined
? null
: {
get contextElement() {
return editableRef.current ?? undefined;
},
getBoundingClientRect: () => {
const painted = editableRef.current?.querySelector(
`[data-lint-id="${CSS.escape(id)}"]`
);
if (painted) return painted.getBoundingClientRect();
const selection = editor.read.selection();
const range =
selection && editor.api.dom.resolveDOMRange(selection);
return range?.getBoundingClientRect() ?? new DOMRect();
},
},
[editableRef, editor, id]
);
if (!finding || !focused || readOnly) return null;
return (
<FloatingPopover open>
<FloatingPopoverAnchor element={anchor} />
<FloatingPopoverContent
align="start"
className="flex w-auto max-w-80 flex-col gap-2 p-3"
data-severity={finding.severity}
data-slot="lint-popover"
onFinalFocus={(event) => event.preventDefault()}
onInitialFocus={(event) => event.preventDefault()}
onMouseDown={(event) => event.preventDefault()}
side="bottom"
>
<p className="text-sm">{finding.message}</p>
<p className="text-xs text-muted-foreground">{finding.ruleId}</p>
{finding.fixes.length > 0 && (
<div className="flex flex-wrap gap-1">
{finding.fixes.map((fix, index) => (
<Button
key={fix}
onClick={() => lint.update.fix(finding.id, index)}
size="sm"
variant="secondary"
>
{fix.trim() === '' ? 'Remove' : fix}
</Button>
))}
<Button
onClick={() => {
if (lint.update.fix(finding.id)) lint.update.move(1);
}}
size="sm"
variant="ghost"
>
Fix and next
</Button>
</div>
)}
</FloatingPopoverContent>
</FloatingPopover>
);
}
export const LintKit = [
toReactPlugin(BaseLintPlugin).configure({
initialState: { rules: [fillerWords, commaSpacing] },
decorate: {
attributes: {
className:
'underline decoration-wavy decoration-from-font underline-offset-4 data-[lint-severity=error]:decoration-destructive data-[lint-severity=info]:decoration-sky-500 data-[lint-severity=warning]:decoration-amber-500',
},
},
shortcuts: {
next: {
keys: 'f8',
handler: ({ editor }) => {
editor.plugin(BaseLintPlugin).update.move(1);
},
},
previous: {
keys: 'shift+f8',
handler: ({ editor }) => {
editor.plugin(BaseLintPlugin).update.move(-1);
},
},
},
slots: { afterEditable: LintPopover },
}),
] as const;'use client';
import { type Element, PLUGINS } from 'platejs';
import { BaseLintPlugin, type LintRule } from 'platejs/lint';
import {
type EditableSiblingProps,
toReactPlugin,
useEditor,
useEditorFocused,
useEditorReadOnly,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover'
A rule checks one text block. check receives the block's content text and
returns matches with UTF-16 offsets into that text, a message and optional
fixes. It also receives the block, so a rule can skip code:
import { type Element, PLUGINS } from 'platejs';
import type { LintRule } from 'platejs/lint';
const isProse = (block: Element) => block.type !== PLUGINS.codeBlock;
export const commaSpacing: LintRule = {
id: 'style/comma-spacing',
severity: 'error',
check: (text, { block }) =>
isProse(block)
? Array.from(text.matchAll(/ , ?/g), (match) => ({
start: match.index,
end: match.index + match[0].length,
message: 'Remove the space before commas.',
fixes: [', '],
}))
: [],
};import { type Element, PLUGINS } from 'platejs';
import type { LintRule } from 'platejs/lint';
const isProse = (block: Element) => block.type !== PLUGINS.codeBlock;
export const commaSpacing: LintRule = {
id: 'style/comma-spacing',
severity: 'error',
check: (text, { block }) =>
isProse(block)
? Array.from(text.matchAll(/ , ?/g), (match
severity is 'error', 'warning' or 'info'. The plugin drops a match whose
offsets are not integers inside the text, or that selects nothing. When a rule
throws, the block keeps the findings of its other rules and the store's error
holds the failure.
Set the rules through initialState, or pass new rules to api.run:
editor.plugin(BaseLintPlugin).api.run([commaSpacing, fillerWords]);editor.plugin(BaseLintPlugin).api.run([commaSpacing, fillerWords]);A rule whose check returns a promise is a server rule. Abort the request
through signal:
const terminology: LintRule = {
id: 'server/terminology',
severity: 'info',
check: async (text, { signal }) => {
const response = await fetch('/api/lint', {
body: JSON.stringify({ text }),
method: 'POST',
signal,
});
return response.json();
},
};const terminology: LintRule = {
id: 'server/terminology',
severity: 'info',
check: async (text, { signal }) => {
const response = await fetch('/api/lint', {
body: JSON.stringify({ text }),
method: 'POST',
signal,
});
return response.json();
},
};The plugin keeps a reply only while the block matches the one it sent,
including its type, marks and inline elements. When the block changes before
the reply arrives, the plugin drops the
reply and sends the latest block, even when the old request failed. Typing in a block hides that block's server findings until the reply for
its new text arrives. Each block and rule has at most one request in flight, and
each rule at most four. The rest wait, and a block whose text changed since its
last request goes ahead of blocks not checked yet. clear, run and
removing the block abort the request through signal. A rejected request sets
the store's error.
api.findings() returns the findings in document order, as the same array until
the store's version moves. Subscribe to the store to read it in React:
import { BaseLintPlugin } from 'platejs/lint';
import { useEditor } from 'platejs/react';
import * as React from 'react';
export function useLintFindings() {
const lint = useEditor().plugin(BaseLintPlugin);
return React.useSyncExternalStore(
lint.store.subscribe,
lint.api.findings,
lint.api.findings
);
}import { BaseLintPlugin } from 'platejs/lint';
import { useEditor } from 'platejs/react';
import * as React from 'react';
export function useLintFindings() {
const lint = useEditor().plugin(BaseLintPlugin);
return React.useSyncExternalStore(
lint.store.subscribe,
lint.api.findings,
lint.api.findings
);
}A finding holds its id, ruleId, severity, message, matched text and
fixes, with no offsets. Pass its id to select or fix:
function LintList() {
const lint = useEditor().plugin(BaseLintPlugin);
const findings = useLintFindings();
return (
<ol>
{findings.slice(0, 50).map((finding) => (
<li key={finding.id}>
<button onClick={() => lint.update.select(finding.id)} type="button">
{finding.message}
</button>
{finding.fixes.length > 0 && (
<button onClick={() => lint.update.fix(finding.id)} type="button">
Fix
</button>
)}
</li>
))}
</ol>
);
}function LintList() {
const lint = useEditor().plugin(BaseLintPlugin);
const findings = useLintFindings();
return (
<ol>
{findings.slice(0, 50).map((finding) => (
<li key={finding.id}>
<button onClick={() => lint.update.select(finding.id)} type="button">
{finding.message}
</button>
{finding.fixes.length > 0
Ids survive an edit in the same block that leaves its findings unchanged, such
as typing before a finding. Any other change to the block's findings, from an
edit or a server reply, gives all of them new ids, and fix and select return
false for an old one.
A collaborator's edit commits like a local one, so the plugin re-checks each block it changes. In a suggesting editor, a fix becomes a suggestion like any other edit. The replaced text stays as a struck suggestion that rules never see, and the inserted fix counts as content.
useLintFindingAt returns the finding under the selection focus. It reads again
after store changes and commits, so a server reply that lands under a still
caret opens LintPopover:
export function useLintFindingAt() {
const editor = useEditor();
const lint = editor.plugin(BaseLintPlugin);
const subscribe = React.useCallback(
(onChange: () => void) => {
const offStore = lint.store.subscribe(onChange);
const offCommit = editor.subscribeCommit(onChange);
return () => {
offStore();
offCommit();
};
},
[editor, lint]
);
return React.useSyncExternalStore(subscribe, lint.api.at, lint.api.at);
}export function useLintFindingAt() {
const editor = useEditor();
const lint = editor.plugin(BaseLintPlugin);
const subscribe = React.useCallback(
(onChange: () => void) => {
const offStore = lint.store.subscribe(onChange);
const offCommit = editor.subscribeCommit(onChange);
return () => {
offStore();
offCommit();
};
},
[editor, lint]
);
LintPopover opens while the editor is focused and editable. It shows the
message, the rule id, one button per fix and Fix and next, which applies the
first fix and selects the next finding. Its buttons keep focus in the editor,
so typing continues after the fixed text.
| Key | Behavior |
|---|---|
F8 | Select the next finding, wrapping to the first. |
Shift+F8 | Select the previous finding, wrapping to the last. |
When findings overlap, F8 and the popover reach the first one.
Use the headless plugin without the copied UI:
import { createEditor } from 'platejs';
import { BaseLintPlugin } from 'platejs/lint';
const editor = createEditor({
initialValue,
plugins: [
BaseLintPlugin.configure({ initialState: { rules: [commaSpacing] } }),
],
});
const lint = editor.plugin(BaseLintPlugin);
const [first] = lint.api.findings();
if (first) lint.update.fix(first.id);import { createEditor } from 'platejs';
import { BaseLintPlugin } from 'platejs/lint';
const editor = createEditor({
initialValue,
plugins: [
BaseLintPlugin.configure({ initialState: { rules: [commaSpacing] } }),
],
});
const lint = editor.plugin(BaseLintPlugin);
const [first] = lint.api.findings();
if (first) lint.update.fix(first.id);In React, lift it with toReactPlugin and style its decorations through
decorate.attributes. Each decoration carries data-lint-id, data-lint-rule
and data-lint-severity:
import { BaseLintPlugin } from 'platejs/lint';
import { toReactPlugin } from 'platejs/react';
const LintPlugin = toReactPlugin(BaseLintPlugin).configure({
initialState: { rules: [commaSpacing] },
decorate: {
attributes: {
className:
'underline decoration-wavy data-[lint-severity=error]:decoration-destructive',
},
},
});import { BaseLintPlugin } from 'platejs/lint';
import { toReactPlugin } from 'platejs/react';
const LintPlugin = toReactPlugin(BaseLintPlugin).configure({
initialState: { rules: [commaSpacing] },
decorate: {
attributes: {
className:
'underline decoration-wavy data-[lint-severity=error]:decoration-destructive',
},
},
});The plugin checks the main document only, not text in nested editable areas inside blocks.
The store also holds version, which moves whenever the findings list changes,
and error, the last rule failure.
Lint the whole document again and repaint.
Stop linting, drop every finding and abort server requests until the next
run.
Replace a finding's text with one of its fixes, as its own undo step.