Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Drag & Drop
    • Navigation Feedback
    • Linting
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • DOCX
    • HTML
    • Markdown

Linting

PreviousNext

Check text blocks with your own rules, underline findings by severity, and fix them from a popover or a list.

Lint ComponentLint Demo
Loading…
Navigation FeedbackTabbable

On This Page

FeaturesKit UsageInstallationAdd KitRulesServer rulesFindings listCollaboration and suggestionsCaret popoverKeyboard behaviorManual UsagePluginsBaseLintPluginAPI Referenceapi.findingsapi.atapi.runapi.clearupdate.fixupdate.selectupdate.moveLintRuleRelated docs
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

  • Checks only the text blocks a commit changed, and calls each rule once per changed block.
  • Reads a block's text through inline elements and leaves out non-content text, such as struck suggestions.
  • Paints findings as decorations, so nothing is written into the document or its history.
  • Lists findings in document order, and rebuilds the list only after findings change or blocks move.
  • Applies a fix as its own undo step, and refuses a finding whose text has changed.
  • Keeps a server reply only while its block matches the one it sent, marks and properties included.
  • Moves between findings with F8 and Shift+F8 in the kit.
Report an issue

Kit Usage

Installation

pnpm dlx shadcn@latest add @plate/lint
pnpm dlx shadcn@latest add @plate/lint

Add 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'






















































































































































































Rules

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]);

Server rules

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.

Findings list

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.

Collaboration and suggestions

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.

Caret popover

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.

Keyboard behavior

KeyBehavior
F8Select the next finding, wrapping to the first.
Shift+F8Select the previous finding, wrapping to the last.

When findings overlap, F8 and the popover reach the first one.

Manual Usage

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.

Plugins

BaseLintPlugin

Options

    The rules to run. Default: [].

    Whether the plugin lints. clear sets it to false and run to true. Default: true.

The store also holds version, which moves whenever the findings list changes, and error, the last rule failure.

API Reference

api.findings

Returns

    Findings in document order, the same array until version moves. Empty while the plugin is cleared.

api.at

Returns

    The finding under the selection focus, read from its block alone.

api.run

Lint the whole document again and repaint.

Parameters

    Rules that replace the current ones.

api.clear

Stop linting, drop every finding and abort server requests until the next run.

update.fix

Replace a finding's text with one of its fixes, as its own undo step.

Parameters

    The finding's id.

    The fix to apply. Default: 0.

Returns

    Whether the fix was applied. false when the finding or the fix is gone.

update.select

Parameters

    The finding to select.

Returns

    Whether the finding still exists and was selected.

update.move

Parameters

    Select the previous or next finding from the selection, wrapping.

Returns

    Whether a finding was selected.

LintRule

Attributes

    The rule's id, copied to each finding as ruleId.

    The severity of the rule's findings.

    Checks one block's content text. Each LintMatch is { start, end, message, fixes? }, with UTF-16 offsets into text.

Related docs

  • Find
  • Plugin Shortcuts
  • Authored Changes
;
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;
)
=>
({
start: match.index,
end: match.index + match[0].length,
message: 'Remove the space before commas.',
fixes: [', '],
}))
: [],
};
&&
(
<button onClick={() => lint.update.fix(finding.id)} type="button">
Fix
</button>
)}
</li>
))}
</ol>
);
}
return React.useSyncExternalStore(subscribe, lint.api.at, lint.api.at);
}