Quality tools
On this page
Three opt-in tools help authors publish content that works for everyone: an accessibility checker, a link checker and visual aids that show block structure and invisible characters. Each is its own import, so an editor that does not use one does not bundle it. apps/playground/quality.html shows all three on a document with seeded problems.
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { visualAidsFeature } from '@better-rich-text/ui';
import { createA11yCheckPlugin } from '@better-rich-text/plugin-a11y-check';
import { createLinkCheckPlugin } from '@better-rich-text/plugin-link-check';
createRichTextEditor('#editor', {
plugins: [createA11yCheckPlugin(), createLinkCheckPlugin({ checker: checkOnMyServer })],
toolbar: `${standardPreset.toolbar} | a11yCheck linkCheck visualBlocks visualChars`,
features: [...standardPreset.features, visualAidsFeature()],
});Accessibility checker
@better-rich-text/plugin-a11y-check checks the document after every edit (once typing pauses, debounceMs, default 400 ms). The a11yCheck toolbar button shows the number of errors and warnings and opens the issues panel below the content.
Rules
| Rule | Severity | Finds | Fixes |
|---|---|---|---|
image-alt |
error | Images with no alternative text | Add text; mark decorative |
image-decorative-link |
error | A linked image marked decorative, so the link has no name | Add text |
image-alt-quality |
warning | Alternative text that is a file name or a generic word; very long text (notice) | Rewrite |
link-text |
warning, error when empty | "click here", "read more", empty links | Rewrite text; remove link |
heading-empty |
error | Headings with no text | Change to paragraph (safe) |
heading-multiple-h1 |
warning | More than one H1 | Change to H2 |
heading-order |
warning | Skipped levels (H2 then H4) | Change to the next level |
duplicate-anchor |
error | The same anchor id twice | Make unique (safe) |
table-header |
error | Tables with two or more rows and no header cells | Make the first row a header |
table-caption |
notice | Tables with no caption | Add caption |
contrast |
error | Coloured text or highlights under 4.5:1 (3:1 for large text) | Use a nearby accessible colour (safe); remove colour or highlight |
all-caps |
warning | Runs of capitals with 24 or more letters | Change to sentence case |
fake-list |
warning | Two or more paragraphs starting with •, -, *, 1. or a) | Convert to a real list (safe) |
document-language |
warning, error when invalid | No document language | Set language |
media-alternatives |
warning | Video without captions; embeds without a title | Add title |
Contrast uses the text colour, highlight and table cell shading in the document against the editing surface's colours, which the plugin reads from the page. If a colour cannot be resolved exactly (unknown background, transparency, a keyword it does not know) the result is a notice asking for a manual check. It never reports a pass it could not measure. Large text is 18pt, or 14pt bold, from the font size mark; H1 to H3 count as large.
Issues
Each issue has a stable id (rule:nodeId), severity, category, message, help, the nodeId and its path from the root, a text range where it is about part of a block, evidence (for example the measured ratio and colours), manual for results a person has to judge, and fixes. Ids survive unrelated edits, and an issue disappears as soon as its content is repaired.
Panel
The panel lists issues in document order with a text severity label (Error, Warning, Review), never colour alone. It filters by severity, announces result changes through a polite live region and works from the keyboard; Escape closes it and returns focus to the editor. Selecting a message selects the content in the document and outlines it. Fixes that need author input (alternative text, link text, captions, the document language) open a small form. "Fix all safe issues" applies only fixes marked deterministic, as one undo step. "Ignore" hides an issue until its content changes.
The panel can also be mounted anywhere with new A11yPanel({ editor, view, mount, controller }).
Commands
| Command | Input | Result |
|---|---|---|
a11y.check |
none | Counts and issue summaries |
a11y.applyFix |
{ issueId, fixId, value? } |
The fixed issue; stale-target if the issue no longer exists |
a11y.fixAll |
none | Ids of the issues fixed |
a11y.ignore |
{ issueId, reason? } |
|
a11y.select |
{ issueId } |
Selects the issue's content |
a11y.next |
{ backward? } |
Selects the next issue (Alt+Shift+F9) |
Fixes are typed transaction edits: they are undoable, replayed in collaboration like any command, and re-validated against the current document before they run.
Custom rules
The a11y.rules filter receives the rule list; a11y.issues receives the results; a11y.checked runs after each check.
import type { A11yRule } from '@better-rich-text/plugin-a11y-check';
const noPlaceholder: A11yRule = {
id: 'no-lorem',
category: 'text',
severity: 'warning',
description: 'Placeholder text left in the document',
*check(ctx) {
for (const leaf of ctx.tree.textLeaves()) {
if (leaf.text.includes('Lorem ipsum')) yield { nodeId: ctx.tree.parentId(leaf.id)!, message: 'Placeholder text left in' };
}
},
};
editor.editor.hooks.addFilter('a11y.rules', (rules) => [...rules, noPlaceholder]);Options: disable (rule ids), rules (replace the set), colors, ambiguousLinkText, allCapsMinLetters, suggestedLanguage, debounceMs, checkOnLoad, resolveColors and saved ignores.
On the server
The same rules run without a DOM:
import { audit } from '@better-rich-text/plugin-a11y-check/audit';
const report = audit(envelope, schema, { colors: { text: '#1f1f1f', background: '#ffffff' } });
if (report.counts.error > 0) rejectPublish(report.issues);Every report carries a note that automated checks find only some problems. A clean report is not proof that content is accessible.
Link checker
@better-rich-text/plugin-link-check checks every link after each edit. Without any setup it validates destinations offline:
unsafe:javascript:,vbscript:,data:(other than images),file:and any scheme outside the allowed list, including versions hidden with whitespace, entities or control characters.invalid: malformed URLs, bad percent-encoding, spaces, invalid host names, email addresses and phone numbers.empty: no destination, or just#.missing-anchor:#namelinks with no matching anchor in the document. Add anchors from elsewhere with thelinkCheck.anchorsfilter.duplicate-text: the same link text pointing to different places.
Reachability
The editor never fetches links itself. Pass a checker that asks your server, which can apply network policy, redirect limits and response size limits:
createLinkCheckPlugin({
checker: async (url, { signal }) => {
const response = await fetch(`/api/check-link?url=${encodeURIComponent(url)}`, { signal });
return response.json(); // { status: 'valid' | 'missing' | ..., httpStatus?, message? }
},
});Statuses are valid, missing, unauthorized, unreachable, blocked, timeout, rateLimited, unsupported and unknown. httpStatusToLinkStatus maps HTTP codes: 401 and 403 are unauthorized, not missing. Each destination is checked once and cached; changing a link's URL checks the new one. Checks run at most concurrency (4) at a time, time out after timeoutMs (10 s) and are cancelled when the editor is destroyed. Relative links are checked only when you give a baseUrl. sameOriginChecker({ origin }) is a ready-made checker for links to your own site; it reports other origins as unsupported.
Results never change a destination by themselves.
Panel and decorations
Problem links get a wavy (broken) or dotted (warning) underline through the CSS Custom Highlight API, so the content DOM is untouched. The linkCheck toolbar button shows the problem count and opens a panel with each link's status, text and address, and the actions show, edit, open (safe http and https links only, in a new tab without opener), unlink, retry and ignore.
Commands: linkCheck.check { force? }, linkCheck.select, linkCheck.unlink, linkCheck.retry and linkCheck.ignore, each taking { linkId }. Hooks: linkCheck.anchors, linkCheck.results and linkCheck.checked.
Visual aids
visualAidsFeature() from @better-rich-text/ui adds two toolbar toggles:
visualBlocksoutlines block elements and labels each with its tag (P, H2, LI, TD, ...).visualCharsshows invisible characters: spaces·, non-breaking spaces°, tabs→, line breaks↵, paragraph ends¶, soft hyphens¬, zero-width characters¦and direction controls⇄(zero-width and direction controls show their code point on hover).
Both are view-only. Block outlines are a class on the editable element plus CSS. Characters are drawn in an overlay beside the content, positioned from the rendered text and computed from the document model. Nothing is added to the document, its HTML, the clipboard or any export. The overlay redraws after edits and resizes, and stops after 5,000 glyphs (limit).
Use them without the toolbar through visualAidsFor(view), which returns an object with setBlocks, setCharacters, toggleBlocks, toggleCharacters and subscribe. findInvisibles(schema, root) lists the invisible characters of a document without a DOM.