Skip to main content

code-language

import {
codeRuns,
languageForTag,
tokenizeText,
streamLanguage,
sql,
} from '@react-x11/components/code-language';

The language seam: tokenizers, the token vocabulary, the built-in languages, the token palettes and the static-highlighting helpers. <CodeEditor>, <Code> and <Markdown>'s fenced blocks all stand on this.

It registers nothing and has no side effect at import time. Importing a language costs exactly that language.

The seam

interface Language {
name: string;
data?: LanguageData;
createTokenizer(host: TokenizerHost): Tokenizer;
}

A named factory for tokenizers, not a tokenizer: each editor gets its own, built against a TokenizerHost that hands it the document's lines. A Tokenizer is line-state shaped — handed a line and the state the previous line ended in, it returns that line's tokens and the state it ends in. That shape is deliberate: it is CodeMirror 5's, it is TextMate's, and it is what makes a single-line edit re-tokenize one line instead of the document. LineEdit is how the editor tells the tokenizer what changed, so cached state below the edit survives.

LanguageData is the small bag of facts the editor's own verbs read:

interface LanguageData {
lineComment?: string; // '--', '#', '//' — enables Ctrl+/
wordChars?: string; // extra word characters for double-click and Ctrl+arrow
completions?: readonly string[]; // words for keywordCompletionSource
indentAfter?: RegExp; // indent one unit deeper after a matching line
}

Built-in languages

Zero dependencies, hand-written stream tokenizers:

sql(options?); // SqlOptions — dialect keywords, schema-aware
shell();
glsl();
javascript(options?); // JavascriptOptions — { typescript: true } for TS
json();

Writing one

streamLanguage(mode) takes a CodeMirror-5-shaped StreamMode — a startState() and a token(stream, state) — and is about fifty lines for a real language. StringStream is the scanner it hands you.

const toml = streamLanguage({
name: 'toml',
startState: () => ({}),
token(stream) {
if (stream.match(/^#.*/)) return 'comment';
if (stream.match(/^\[[^\]]*\]/)) return 'heading';
stream.next();
return null;
},
});

Override copyState and stateEquals together when the state is not a flat bag of primitives. lineModeLanguage(mode) is the layer underneath, for a mode that tokenizes a whole line at once rather than through a stream.

Adapters to the grammar worlds

lezerLanguage({ name, parser }); // any @lezer/<lang> parser
textMateLanguage({ name, grammar }); // an initialized TextMate grammar
hljsLanguage({ hljs, name }); // highlight.js, by language name or alias

None of the three ships with this package — install the one you want. TextMate grammars come through vscode-textmate or shiki's core; their tokenizer is line-state shaped too, so it drops straight in. LezerParserLike, TextMateGrammarLike and HljsLike are structural types, so no version of any of those libraries is pinned by this package.

highlight.js, for breadth

The built-in tokenizers cover the languages worth writing by hand. hljsLanguage covers the rest — about thirty more in highlight.js's common build, at an accuracy nobody wants to re-derive — which is what a document full of arbitrary fences needs:

import hljs from 'highlight.js/lib/common';
import { hljsLanguage } from '@react-x11/components/code-language';

<Markdown resolveLanguage={(tag) => hljsLanguage({ hljs, name: tag })} />;

It returns null for a name this build of highlight.js does not know, which is the answer resolveLanguage expects — the fence then falls through to the built-ins and finally to plain text. The app picks the build: lib/common, or lib/core plus its own registrations, or the full one.

highlight.js has no incremental mode, so the adapter highlights the whole document and slices it per line, re-running on edit. That is right for a fence, a <Code> block or a file of a few hundred lines, and wrong for a megabyte under a caret — put a Lezer grammar behind <CodeEditor> there.

scopeTypes re-aims a scope ({ attribute: 'variableName' }), and data supplies the lineComment and friends that highlight.js does not describe.

Token palettes

LIGHT_TOKEN_STYLES;
DARK_TOKEN_STYLES;
TOKEN_FALLBACK; // the style an unknown token type gets
tokenStyleFor(type, styles);
autoTokenStyles(background); // picks the palette that will be legible
isDarkBackground(color);

autoTokenStyles is the one to reach for. Deciding from the actual background rather than a light/dark flag is what makes a custom theme work without declaring which it is.

A TokenStyle is a colour plus optional weight and italic; a TokenStyles maps TokenType to one. Colours may be '$token' names, resolved against the live theme by codeblock's themeTokenResolver.

Static highlighting

languageForTag('tsx'); // the built-in registry, by fence tag
tokenizeText(source, language); // whole-document tokens, no editor involved
codeRuns(text, tag, opts); // straight to <richtext> runs

codeRuns is what <Code> and <Markdown> use, through codeblock's codeBlockRuns. Its CodeRunOptions carries the palette (styles), the plain-text color for gaps and unstyled tokens, an optional resolveToken for '$token' colour names, an explicit language that takes precedence over the tag, and a resolveLanguage(tag) consulted before the built-in registry — the seam hljsLanguage goes through. An empty tag with no language is how "do not highlight this" is said.