core

speed-highlight

NPM Version NPM Downloads

A tiny, fast, simple syntax highlighter for the web and the terminal in JavaScript

Playground

Screenshot

Quick start

npm i @speed-highlight/core

In a terminal, print the highlighted string:

import { highlightANSI } from '@speed-highlight/core';
import theme from '@speed-highlight/core/themes/default.js';

console.log(await highlightANSI('console.log("hello")', 'js', theme));

In a component, highlight a string and render it:

import { useEffect, useState } from 'react';
import { highlightHTML } from '@speed-highlight/core';
import '@speed-highlight/core/themes/default.css';

export function Code({ code, lang }) {
	const [html, setHtml] = useState('');

	useEffect(() => {
		// highlightHTML is async (languages load on first use), skip stale results
		let stale = false;
		highlightHTML(code, lang).then(result => { if (!stale) setHtml(result); });
		return () => { stale = true; };
	}, [code, lang]);

	return <div className={`shj-lang-${lang} shj-block`} dangerouslySetInnerHTML= />;
}

How it works

The tokenizer runs a language’s regex rules over your code and emits typed tokens (kwd, str, cmnt, …). On the web each token becomes a <span class="shj-syn-kwd">; in the terminal it becomes an ANSI escape code. A theme is just CSS (or a token-to-escape map) coloring those names, which is why themes are under 1 kB and writing your own is a few lines.

Comparison

Highlighters trade size for grammar fidelity: TextMate engines (Shiki, starry-night) are the most faithful and heaviest, mature regex engines (highlight.js, Prism) sit in the middle, lightweight regex tokenizers (speed-highlight, sugar-high) are the smallest and approximate on exotic syntax.

  Core (gzip) Per language (gzip) Languages Grammar model Terminal Status Choose it for
speed-highlight 1.5 kB 0.07–1.6 kB ~30 lightweight regex ✅ built in ✅ v2 (this repo) runtime highlighting where size and startup matter
sugar-high 1.7 kB 0.18–2.9 kB 25 lightweight regex ✅ v2.0.0 CSS-variable theming, JSX/TSX-aware JavaScript
Prism 3.1 kB 0.3–3 kB ~290 mature regex ⚠️ v1.30.0, frozen since Mar 2025 (v2 rewrite) its plugin ecosystem
highlight.js 8.3 kB 0.3–2.5 kB ~190 mature regex ❌ (via wrappers) ✅ v11.11 (Jun 2026) broad auto-detection and rare languages
Shiki 35 kB + engine: 145 kB WASM or 20 kB JS 5–16 kB ~220 TextMate (VS Code) ✅ via @shikijs/cli ✅ v4.4, very active highest fidelity (the same grammars as VS Code); zero client JS when run at build time
starry-night 185 kB incl. WASM 3–25 kB 600+ TextMate (GitHub) ✅ active GitHub-identical rendering in Node

Sizes are min+gzip, measured from the installed packages at the versions shown (per-language = range over the benchmark corpus; starry-night figures from its own README). Wrappers reuse these engines and inherit their numbers: lowlight/refractor wrap highlight.js/Prism for virtual DOMs, rehype-pretty-code and bright wrap Shiki, and the terminal-only emphasize and cli-highlight wrap highlight.js grammars into ANSI. Editors (CodeMirror, Monaco, tree-sitter) are a different category.

If you highlight at build time and bytes do not matter, use Shiki. speed-highlight’s case is the opposite one: highlighting at runtime, where the entire library with all 34 grammars bundled into one file gzips to 9.0 kB, barely more than highlight.js’s core alone, before it has loaded a single grammar.

Web usage

In a component

Frameworks own their DOM, so highlight the string and render it, as in the quick start (mutating a mounted node with highlightElement gets wiped on the next render). The output is HTML-escaped (&, <, >), safe to inject even for untrusted code; the shj-lang-* and shj-block classes hook it into the theme. The same pattern works in Vue, Svelte, and Angular; ready-made components are in #85.

On a plain page

Mark code blocks with a shj-lang-* class and call highlightAll once:

<div class="shj-lang-js">console.log('hello')</div>
<code class="shj-lang-js">inline code</code>

<script type="module">
	import { highlightAll } from '@speed-highlight/core';
	highlightAll();
</script>

Blocks are a single <div> instead of <pre><code> so the line-number gutter can be laid out inside; the shj-lang- prefix avoids colliding with Prism’s language-* during a migration.

For per-element control use highlightElement. It renders a code element inline and anything else as a block, accepts block as an override, and sets data-lang so a theme can render a language header with content: attr(data-lang):

import { highlightElement } from '@speed-highlight/core';

await highlightElement(element, 'js', { showLineNumbers: true });

Detect the language

Detection is a separate ~1 kB import so the core stays small. It recognizes about 20 common languages and returns 'plain' when unsure:

import { highlightElement } from '@speed-highlight/core';
import { detectLanguage } from '@speed-highlight/core/detect';

element.textContent = code;
await highlightElement(element, detectLanguage(code));

Control loading and bundling

Languages load lazily through a loader. Replace it with setLoader to add custom languages or restrict what your bundler includes; a name the loader cannot resolve renders as plain text:

import { setLoader, defaultLoader } from '@speed-highlight/core';

// add custom languages on top of the bundled ones
setLoader(name => customs[name] ?? defaultLoader(name));

// or allow only the languages your bundler can code-split
setLoader(name => ({
	js: () => import('@speed-highlight/core/languages/js.js'),
	css: () => import('@speed-highlight/core/languages/css.js'),
})[name]?.());

For full tree-shaking skip the loader entirely: tokenizeWith takes every language from the caller, so a bundler keeps only what you import. Include the sub-languages a grammar embeds (html uses css and js; js uses jsdoc, todo, and regex). A sub that is not given keeps the type of its rule and only skips the inner highlighting:

import { tokenizeWith } from '@speed-highlight/core/tokenize';
import { html, css, js, jsdoc, todo, regex } from '@speed-highlight/core/languages';

tokenizeWith(code, html, (str, type) => { /* ... */ }, { languages: { css, js, jsdoc, todo, regex } });

[!NOTE] highlightHTML and tokenizeWith never touch the DOM, so they also run server-side or in a web worker: highlight there and send the string over.

CDN (no build step)

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@speed-highlight/core@2/dist/themes/default.css">
<script type="module">
	import { highlightAll } from 'https://cdn.jsdelivr.net/npm/@speed-highlight/core@2/dist/index.js';
	highlightAll();
</script>

Terminal usage

highlightANSI returns a string ready to print; the theme is required, import one from themes/*.js:

import { highlightANSI } from '@speed-highlight/core';
import theme from '@speed-highlight/core/themes/atom-dark.js';

console.log(await highlightANSI(code, 'js', theme));

A terminal theme is a plain token-to-escape map, built with the termcolor helpers or raw escapes:

import * as col from '@speed-highlight/core/themes/termcolor.js';

export default {
	kwd: col.red,
	str: col.green,
	cmnt: col.gray,
};

For Deno, use the deno module:

import { highlightANSI } from 'https://deno.land/x/speed_highlight_js/dist/index.js';
import theme from 'https://deno.land/x/speed_highlight_js/dist/themes/default.js';

console.log(await highlightANSI('console.log("hello")', 'js', theme));

API

The main entry covers most apps; reach for /tokenize when you want the raw token stream and full control over what gets bundled. Everything ships TypeScript types.

Entry Export Description
@speed-highlight/core highlightAll(opt?) Highlight every element with a shj-lang-* class
  highlightElement(elm, lang?, opt?) Highlight one element (language read from its class by default)
  highlightHTML(src, lang, opt?) Highlight a string, resolves to an HTML string
  highlightANSI(src, lang, theme) Highlight a string, resolves to an ANSI string for terminals
  tokenize(src, lang, onToken) Loader-based tokenizer, calls onToken(text, type)
  setLoader(loader) / defaultLoader Replace or compose how language names are resolved
.../detect detectLanguage(code) Guess the language, 'plain' when unsure
.../tokenize tokenizeWith(src, lang, onToken, opt?), tokenizer Registry-free synchronous tokenizer (and the underlying generator), languages passed by the caller
.../languages one named export per language Grammars, import only what you need
.../themes/*.css   Web themes
.../themes/*.js   Terminal themes, plus termcolor.js helpers

lang is a name ('js') or a grammar object passed directly. opt is { block?: boolean, showLineNumbers?: boolean }: line numbers are opt-in, block defaults to true, except that highlightElement and highlightAll read it off the element instead, where a code element is inline and anything else is a block.

Languages

| Name | CSS Class | Support | Detection | Size (gzip, 14.2 kB total) | | — | — | — | — | — | | Assembly | shj-lang-asm | | ✅ | 194 B | | Bash | shj-lang-bash | | ✅ | 430 B | | Brainfuck | shj-lang-bf | increment, operator, print, comment | ❌ | 137 B | | C | shj-lang-c | | ✅ | 429 B | | CSS | shj-lang-css | comment, str, selector, units, function, … | ✅ | 343 B | | CSV | shj-lang-csv | punctuation, … | ❌ | 96 B | | Diff | shj-lang-diff | | ✅ | 144 B | | Dockerfile | shj-lang-docker | | ✅ | 566 B | | Git | shj-lang-git | comment, insert, deleted, string, … | ❌ | 222 B | | Go | shj-lang-go | | ✅ | 329 B | | HTML | shj-lang-html | | ✅ | 627 B | | HTTP | shj-lang-http | keywork, string, punctuation, variable, version | ✅ | 986 B | | INI | shj-lang-ini | | ❌ | 158 B | | Java | shj-lang-java | | ✅ | 457 B | | JavaScript | shj-lang-js | basic syntax, regex, jsdoc, json, template literals | ⛔ reported as TypeScript | 758 B | | JSDoc | shj-lang-jsdoc | | ❌ | 247 B | | JSON | shj-lang-json | string, number, bool, … | ❌ | 172 B | | LeanPub Markdown | shj-lang-leanpub-md | | ❌ | 1.2 kB | | Log | shj-lang-log | number, string, comment, errors | ❌ | 223 B | | Lua | shj-lang-lua | | ✅ | 273 B | | Makefile | shj-lang-make | | ✅ | 223 B | | Markdown | shj-lang-md | | ✅ | 1.1 kB | | Perl | shj-lang-pl | | ✅ | 329 B | | Plain text | shj-lang-plain | | ❌ | 71 B | | Python | shj-lang-py | | ✅ | 416 B | | Regex | shj-lang-regex | count, set, … | ❌ | 172 B | | Rust | shj-lang-rs | | ✅ | 414 B | | SQL | shj-lang-sql | number, string, function, … | ✅ | 1.7 kB | | TODO | shj-lang-todo | | ❌ | 185 B | | TOML | shj-lang-toml | comment, table, string, bool, variable | ❌ | 236 B | | TypeScript | shj-lang-ts | js syntax, ts keyword, types | ✅ | 849 B | | URI | shj-lang-uri | | ✅ | 176 B | | XML | shj-lang-xml | | ✅ | 511 B | | YAML | shj-lang-yaml | comment, numbers, variable, string, bool | ✅ | 208 B |

Themes

| Name | Terminal (gzip) | Web (gzip) | | — | — | — | | default | ✅ 174 B | ✅ 603 B | | atom-dark | ✅ 174 B | ✅ 699 B | | dark | ❌ | ✅ 695 B | | github-dark | ❌ | ✅ 691 B | | github-dim | ❌ | ✅ 700 B | | github-light | ❌ | ✅ 672 B | | visual-studio-dark | ❌ | ✅ 694 B |

Custom languages

A language is an array of rules. Every rule’s regex (global flag required) is tried; the earliest match in the string wins, ties go to the earlier rule:

export default [
	{ match: /\/\/.*/g, type: 'cmnt' },
	{ expand: 'str' },
	{ expand: 'num' },
	{ match: /\b(if|else|for|while|return)\b/g, type: 'kwd' },
];

A language can also set a default token for unmatched text by exporting { type, sub } instead of a bare array (see http.js). Use a grammar by passing it directly as lang, or register a name with setLoader. To extend an existing language, spread it after your rules:

import js from '@speed-highlight/core/languages/js.js';

export default [
	{ match: /\b(signal|effect)\b/g, type: 'func' },
	...js,
];

Token types:

Token Used for   Token Used for   Token Used for
kwd keywords   type types   esc escape sequences
str strings   class classes   section section delimiters
num numbers   var variables   insert inserted parts (diff)
cmnt comments   oper operators   deleted deleted parts (diff)
func functions   bool booleans   err errors

Missing a language? Open an issue or send a PR adding a file to src/languages/.

Custom themes

A web theme colors the token classes; start from default.css and override:

[class*="shj-lang-"] { color: #f8f8f2; background: #282a36; }
.shj-syn-kwd { color: #ff79c6; }
.shj-syn-str, .shj-syn-insert { color: #50fa7b; }
.shj-syn-cmnt { color: #6272a4; font-style: italic; }
.shj-numbers { color: #6272a4; }

Display-mode hooks: .shj-inline (inside code), .shj-block, and .shj-numbers for the gutter. Terminal themes are the token-to-escape maps shown in Terminal usage.

Migrating from v1

v1 v2
highlightText(src, lang) highlightHTML(src, lang)
printHighlight(src, lang) from /terminal console.log(await highlightANSI(src, lang, theme))
setTheme('atom-dark') pass the theme: highlightANSI(src, lang, theme)
loadLanguage(name, grammar) setLoader(...) or pass the grammar directly as lang
@speed-highlight/core/terminal entry merged into @speed-highlight/core
common.js shared patterns { expand: 'num' \| 'str' \| 'strDouble' } built into the tokenizer
{ hideLineNumbers: true } now the default, line numbers are opt-in with { showLineNumbers: true }
oneline display mode removed, a div is always a block
highlightElement(elm, lang, mode, opt) the mode moved into the options: highlightElement(elm, lang, { block })
shj-multiline class shj-block

Benchmark

$ npm run benchmark
node v26.7.0, darwin arm64, Apple M4
corpus: js, css, json, md, sql, py, bash, tiled to 3 sizes (tiny (1 KB) / medium (16 KB) / huge (128 KB)), median of 9 trials per language, averaged across the corpus

                              tiny (1 KB)     medium (16 KB)      huge (128 KB)
speed-highlight         1,144,010 ops/min     74,720 ops/min      8,583 ops/min
prismjs                   655,152 ops/min     34,310 ops/min      2,575 ops/min
highlight.js              595,113 ops/min     48,666 ops/min      5,473 ops/min
sugar-high                238,406 ops/min     13,405 ops/min      1,376 ops/min
shiki (js engine)          89,925 ops/min      6,682 ops/min        841 ops/min

cold start (import + first highlight of test.js):
speed-highlight                    7.3 ms
prismjs                            8.6 ms
highlight.js                        19 ms
sugar-high                          18 ms
shiki (js engine)                  169 ms

speed-highlight per language (warm, median of 9 trials):
                              tiny (1 KB)     medium (16 KB)      huge (128 KB)
js                        666,642 ops/min     40,773 ops/min      4,704 ops/min
css                     1,006,895 ops/min     62,774 ops/min      7,284 ops/min
json                    1,964,840 ops/min    127,890 ops/min     15,057 ops/min
md                      1,080,739 ops/min     86,883 ops/min     10,453 ops/min
sql                     1,369,247 ops/min     88,513 ops/min     10,180 ops/min
py                        974,179 ops/min     56,165 ops/min      6,260 ops/min
bash                      945,526 ops/min     60,045 ops/min      6,141 ops/min

Identical inputs from examples/languages/, tiled up to each size bucket, HTML-string output for every library, one op = one highlighted file. Each figure is the median of 9 repeated trials, reported as ops/min (the huge bucket can drop below 1 op/sec for the slower libraries). Warm runs have grammars preloaded; cold start is import plus first highlight, measured once (not size-swept). Shiki does more work by design (see Comparison).