Better Rich Text Buy licence

Code and math

On this page
  1. Code highlighting
  2. Languages
  3. Options
  4. Commands
  5. Keyboard
  6. Hooks
  7. External highlighters
  8. Colours
  9. HTML export
  10. Performance
  11. Math
  12. Typing and editing
  13. Commands
  14. Built-in renderer
  15. KaTeX
  16. Chemistry
  17. HTML
  18. Hooks
  19. Limitations

Two opt-in plugins: @better-rich-text/plugin-code-highlight adds syntax highlighting and a toolbar to code blocks, and @better-rich-text/plugin-math adds inline and display formulas. Neither is in the standard preset, so editors that do not import them do not pay for them.

Code highlighting

TypeScript
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { codeHighlightFeature, createCodeHighlightPlugin } from '@better-rich-text/plugin-code-highlight';
import { javascript } from '@better-rich-text/highlight/languages/javascript';
import { python } from '@better-rich-text/highlight/languages/python';

createRichTextEditor('#editor', {
  plugins: [createCodeHighlightPlugin({ languages: [javascript, python] })],
  features: [...standardPreset.features, codeHighlightFeature],
  toolbar: `${standardPreset.toolbar} | codeLanguage codeLineNumbers codeWrap`,
});

The plugin needs codeBlockPlugin (in the standard preset). Code blocks keep plain text plus language, lineNumbers and wrap attributes. Tokens are painted with the CSS Custom Highlight API through the view.decorations filter, the same mechanism as search and proofing, so highlighting never enters the document, the undo history or the HTML.

Each block gets a bar with the language (a searchable list), line-number and wrap toggles and a copy button. Copy writes the literal code, never line numbers. Blocks in a language without a grammar show plain code and say "no highlighting" next to the language.

Languages

One module per language under @better-rich-text/highlight/languages/:

ModuleAliases
javascript js, jsx, mjs, cjs, node
typescript ts, tsx, mts, cts
json jsonc, json5
html htm, xhtml (embeds javascript and css when loaded)
xml svg, xsl, plist, rss, atom
css
python py
bash sh, shell, zsh, console
sql mysql, postgresql, sqlite, tsql
markdown md, mdx (fenced code uses any loaded grammar)
php, go, rust, java, ruby, yaml, diff golang, rs, rb, yml, patch
c, cpp, csharp h, c++, cc, hpp, cs, c#

languages/all exports allLanguages, which bundles every grammar.

Grammars are small state machines (defineGrammar in @better-rich-text/highlight), so adding a language is a module of regular expressions. The highlighter is incremental: an edit re-tokenizes from the changed line and stops at the first later line that starts in the same state as before.

Options

OptionDefault
languages [] Grammars to bundle.
highlighter none External highlighter adapter.
autoDetect true Guess the language of blocks without one (view only).
bar true false, or { languagePicker, lineNumbers, wrap, copy }.
htmlExport 'plain' 'classes' or 'inline', see below.
maxLength 500 000 Longer blocks are shown without highlighting.
windowLines 300 Longer blocks are painted around the viewport only.
marginLines 40 Lines painted above and below the visible ones.
externalDelayMs 80 Pause after an edit before the external highlighter runs again.

Commands

CommandInput
codeBlock.configure { nodeId?, language?, lineNumbers?, wrap? }
codeBlock.toggleLineNumbers { nodeId? }
codeBlock.toggleWrap { nodeId? }
codeBlock.detectLanguage { nodeId? }, returns { language }

Without nodeId they act on the code block with the caret. The code block plugin's own codeBlock.toggle, codeBlock.setLanguage, codeBlock.indent (Tab), codeBlock.dedent (Shift+Tab) and codeBlock.exit (Mod+Enter) are unchanged. Enter keeps the current line's indentation.

Keyboard

Tab and Shift+Tab indent and outdent inside code. Escape, then Tab, moves focus out of the editor instead of indenting (announced to screen readers). The codeLanguage toolbar item opens the language list for the block with the caret; type to filter, arrow keys to move, Enter to choose, Escape to close.

Hooks

HookKindArguments
code.highlight filter tokens, { blockId, language, text }
code.detectLanguage filter language | null, { blockId, text, languages }
code.copied action { blockId, text }
TypeScript
// Mark TODO comments.
editor.hooks.addFilter('code.highlight', (tokens, { text }) => {
  const at = text.indexOf('TODO');
  return at < 0 ? tokens : [...tokens, { from: at, to: at + 4, type: 'meta' }];
});

External highlighters

An adapter returns { from, to, type } ranges, synchronously or as a promise. Results that arrive after the code or its language changed are discarded; while a result is pending, unchanged lines keep their previous tokens. Nothing is bundled: the host loads the library.

TypeScript
import Prism from 'prismjs';

const prismAdapter = {
  id: 'prism',
  supports: (language) => language in Prism.languages,
  highlight(code, language) {
    const out = [];
    let pos = 0;
    const walk = (stream, type) => {
      for (const token of stream) {
        if (typeof token === 'string') pos += token.length;
        else if (Array.isArray(token.content)) walk(token.content, token.type);
        else {
          const length = String(token.content).length;
          out.push({ from: pos, to: pos + length, type: toType(token.type ?? type) });
          pos += length;
        }
      }
    };
    walk(Prism.tokenize(code, Prism.languages[language]), null);
    return out;
  },
};
// toType maps Prism names (keyword, string, comment, function, class-name → type, …) to token types.

createCodeHighlightPlugin({ highlighter: prismAdapter });

For Shiki, call codeToTokens(code, { lang, theme, includeExplanation: 'scopeName' }) and map TextMate scopes (keyword.*, string.*, comment.*, entity.name.function, …) to token types the same way. Token types are keyword string number comment function type constant
variable operator punctuation property tag attribute regexp builtin meta escape inserted
deleted heading emphasis strong link.

Colours

Each token type is painted as ::highlight(brt-code-<type>) with --brt-code-<type>. Every built-in skin defines code-background, code-text, code-gutter and the token colours (code-keyword, code-string, code-number, code-comment, code-function, code-type, code-constant, code-variable, code-operator, code-punctuation, code-property, code-tag, code-attribute, code-meta, code-inserted, code-deleted) for each of its schemes, at 4.5:1 contrast or better against the code background (7:1 in contrast). The values live in codeSkins in @better-rich-text/skins and are only added to the page by the plugin. Override them like any token:

CSS
.brt-editor { --brt-code-keyword: #8a1f4b; --brt-code-background: #f4f1ea; }

Types without their own token borrow one: regexp and link use string, builtin uses type, escape uses constant, heading and strong use keyword, emphasis uses variable.

HTML export

By default a block exports as <pre><code class="language-x">…</code></pre> with the plain code. htmlExport: 'classes' wraps tokens in <span class="brt-code-keyword">; htmlExport: 'inline' also adds style="color: …" (the light studio colours) for destinations without a stylesheet, such as email. Both parse back to the same plain code.

Performance

Typing re-tokenizes one or two lines of the edited block; other blocks are not touched. Long blocks are painted only around the viewport, and scrolling repaints when the visible part leaves the painted lines. Measured on a 2,000-line JavaScript block (median time to handle a keystroke, before paint, headless browsers on an Apple M-series laptop, e2e/code.spec.ts):

BrowserWithout highlightingWith highlighting
Chromium 6.8 ms 6.3 ms
Firefox 75 ms 27 ms
WebKit 11 ms 12 ms

Firefox is faster with the plugin because its code view does not wrap long lines. WebKit repaints highlight ranges in long editable text slowly after each edit (the cost grows with ranges times lines). The plugin times edits and one settled repaint; where repainting would exceed its budget, it drops the block's ranges while typing and paints them again 400 ms after typing pauses. Chromium and Firefox never defer.

Math

TypeScript
import { mathFeature, mathPlugin } from '@better-rich-text/plugin-math';

createRichTextEditor('#editor', {
  plugins: [mathPlugin],
  features: [...standardPreset.features, mathFeature],
  toolbar: `${standardPreset.toolbar} | math`,
});

mathInline and mathBlock are atoms that store TeX in tex and an optional accessible description. The source is the document; rendering is a view concern, so a formula that fails to render keeps its source, shown in place with the error.

Typing and editing

  • $x^2$ typed in text becomes an inline formula. The closing $ must follow a non-space character, so $5 and $6 stays text. Undo right after restores the typed text.

  • $$ and a space on an empty line makes a display formula and opens its editor; Escape removes it again. $$E = mc^2$$ typed alone on a line becomes a display formula.

  • Click a formula, or select it and press Enter, to edit. The editor shows the TeX source and a live preview with errors. Enter applies, Shift+Enter adds a line, Escape cancels.

  • The math toolbar item inserts a formula (with a "Display on its own line" option) or edits the selected one.

  • createMathPlugin({ inputRules: false }) turns the $ shortcuts off.

Commands

CommandInput
math.insert { tex, display?, description? }, returns { nodeId }
math.update { nodeId?, tex?, description? }
math.remove { nodeId? }

Without nodeId they act on the selected formula. Each is one undoable change.

Built-in renderer

mathmlRenderer() converts a TeX subset to MathML Core, which current browsers render natively. It covers:

  • \frac, \dfrac, \tfrac, \binom, \sqrt, \sqrt[n]

  • ^, _, primes, \limits, \nolimits

  • Greek letters, operators, relations, arrows, dots, \not

  • \sum, \prod, \int, \oint, \bigcup and others, with limits under and over in display style

  • \sin, \log, \lim, \max and other functions, \operatorname

  • \text, \textbf, \mathrm, \mathbf, \mathbb, \mathcal, \mathfrak, \mathsf, \mathtt

  • \hat, \bar, \vec, \dot, \tilde, \overline, \underline, \overbrace, \underbrace

  • \left, \middle, \right, \big sizes, spacing commands, \color, \boxed

  • matrix, pmatrix, bmatrix, vmatrix, cases, aligned, gathered, array, and \\ rows

Unknown commands are errors, never dropped. Commands that define macros or load resources (\def, \newcommand, \href, \url, \includegraphics, HTML extensions) are rejected. Input is limited to 10 000 characters, 48 levels of nesting and 6 000 output nodes. texToMathML(tex, { display }) and mathToString are exported for server-side use.

KaTeX

The host loads KaTeX and its stylesheet; the adapter passes it in. Nothing is bundled.

TypeScript
import katex from 'katex';
import 'katex/dist/katex.min.css';
import 'katex/contrib/mhchem';
import { createMathPlugin, katexRenderer } from '@better-rich-text/plugin-math';

const math = createMathPlugin({ renderer: katexRenderer(katex, { macros: { '\\RR': '\\mathbb{R}' } }) });

The adapter keeps trust: false and bounds macro expansion. Any object with render(tex, element, { display }) can be a renderer; throw to report an error.

Chemistry

\ce{…} and \pu{…} depend on the renderer. The built-in renderer reports them as unsupported; KaTeX with the mhchem extension (above) renders them.

HTML

Inline formulas export as <span class="brt-math" data-math="inline" data-tex="…"> and display formulas as <div class="brt-math brt-math-block" data-math="block" data-tex="…">, each with MathML from the built-in converter (including an application/x-tex annotation). Invalid TeX exports as its source text. Parsing reads data-tex, KaTeX output (span.katex, span.katex-display) and <math> elements with a TeX annotation. MathML without a TeX annotation is not converted.

Hooks

HookKindArguments
math.render filter tex, { display, nodeId } (rewrites what is rendered, not what is stored)
math.renderFailed action { tex, error, nodeId }

Limitations

  • Highlighting needs the CSS Custom Highlight API; without it code shows unhighlighted.

  • Grammars are line-based approximations, not full parsers: heredocs, nested template languages and context-dependent syntax (JSX tags, Ruby % literals with any delimiter) are highlighted partially.

  • In wrapped blocks, line numbers are measured after layout and update on the next frame.

  • The math editor is a source field with preview; there is no visual formula builder or handwriting input yet.