JavaScript syntax highlighter in 12.7 kB of WebAssembly
js-syntax-highlight-html.wasm highlighted 5.56 MB of three.min.js in 265 ms in
Chrome and 263 ms in Node. This was 2.0 times faster than
the impressively-language-agnostic small model gpu-lexer at 533 ms on the same Apple M5.
Chrome executed the component on the CPU, while gpu-lexer used WebGPU. js-syntax-highlight-html.wasm labels agreed with Shiki on 99.97% of non-whitespace characters.
The component accepts raw text/javascript and returns escaped text/html.
The benchmark calls its QIP Content ABI directly from JavaScript.
265 ms10× three.min.js in Chrome
99.97%agreement with Shiki
12.7 kBWasm module
Try it #
Edit the JavaScript. The page uses qipx to load and run the same Wasm component
used in the benchmark. The component escapes the input before the preview
inserts its HTML.
Highlighted result
— ms
Loading WebAssembly…
Loading 12.7 kB Wasm module…
Download js-syntax-highlight-html.wasm (12.71 kB)
Run in qipx cli #
npx @qip.dev/qipx qip.dev run -i input.js \
text/javascript/js-syntax-highlight-html.wasm \
-o highlighted.html
Run in JavaScript #
With a bundler that supports WebAssembly ES module integration, wrap the
component in a JavaScript module:
import {
memory,
input_ptr,
input_utf8_cap,
render,
} from "./js-syntax-highlight-html.wasm";
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8", { fatal: true });
export function highlightJavaScript(source) {
const input = new Uint8Array(memory.buffer, input_ptr(), input_utf8_cap());
const { read, written } = encoder.encodeInto(source, input);
if (read !== source.length) {
throw new RangeError(`Input exceeds the component capacity of ${input.byteLength} bytes`);
}
const result = render(written);
const output = new Uint8Array(memory.buffer, Number((result >> 32n) & 0x7fff_ffffn), Number(result & 0xffff_ffffn));
return decoder.decode(output);
}
Speed comparison #
Time to highlight 10× three.min.js · lower is better
js-syntax-highlight-html.wasm265 ms
gpu-lexer533 ms
Sugar High1.25 s
Prism1.83 s
Starry Night13.19 s
Shiki34.59 s
The input was ten copies of
three@0.97.0/build/three.min.js,
or 5,556,500 bytes. The browser test used CPU WebAssembly in headless Chrome
152 with V8 15.2.124.21. The Node tests used Node.js 26.8.1 and V8
14.6.202.34. All tests ran sequentially on a MacBook Air with an Apple M5, a
10-core GPU, 24 GB of memory, and macOS 26.6.2. Each highlighter had one warm-up
run. The Wasm component and gpu-lexer had 40 measured runs in four batches. The
batch means were 238 to 301 ms for the Wasm component and 497 to 548 ms for
gpu-lexer. Sugar High and Prism had ten measured runs. Starry Night and Shiki
had three because each run took more than ten seconds. The gpu-lexer test used
version 0.0.2.
These libraries return different data structures. HTML generators allocate and
copy markup. Starry Night builds a syntax tree. Shiki returns detailed tokens.
gpu-lexer returns source ranges. The table measures the complete returned value,
not token recognition alone.
Why the component returns less HTML
The component copies plain identifiers, whitespace, and punctuation without
wrappers. It adds a flat span only to comments, strings, numbers, keywords,
types, functions, constants, and operators. Each highlighted run adds 33 to 37
bytes of markup. The component adds no line wrappers, inline styles, or nested
token elements.
Sugar High wraps every token and every line. Its default token markup contains
both a class and an inline color style. Prism also wraps punctuation and can
produce nested token spans. Those choices support their styling models, but
they create more markup for punctuation-heavy minified code.
For this input, the component's 26.09 MB result was 56% smaller than Prism's
HTML and 82% smaller than Sugar High's HTML. Its output is still 4.7 times
larger than the source because three.min.js contains many short highlighted
tokens.
Agreement with Shiki #
Shiki 4.4.3 is the reference when the component and another highlighter
disagree. The score maps Shiki's TextMate scopes and the component's HTML
classes to the nine token types used by gpu-lexer: plain, comment, string,
number, keyword, type, function, constant, and operator.
The score compares the class of each non-whitespace source character. The
runner also decodes the component's HTML and requires it to reproduce the
complete input. The component cannot gain points by dropping text.
These are focused JavaScript regression measurements. They are not comparable
to gpu-lexer's Top-25 score. That score covers many languages and gives an
unsupported language a score of zero.
The four files exercise different JavaScript patterns. React covers anonymous
functions, function-valued properties, CommonJS names, uppercase parameters,
and reserved words used as property names. Three.js covers constructors without
parentheses and nested conditional expressions.
How the executable oracle works #
The project uses two oracle layers:
compare-shiki.mjs downloads four pinned JavaScript files, verifies their byte lengths and SHA-256 digests, runs Shiki and the Wasm component, and reports each disagreement.
- A developer reduces a useful disagreement to a short input and exact HTML output in
syntax-highlight-javascript-semantic.fixtures.txt.
syntax-highlight-javascript-semantic.comply.zig embeds that fixture file and compiles it into a 5.7 kB Compliance module.
qip comply runs those exact cases against the highlighter. A changed byte, missing span, extra span, or escaping error fails the check.
The large files find repeated errors. The small Compliance cases define the
stable contract without storing React, Three.js, Underscore, Lodash, or Shiki
inside the oracle.
Each fixture has three markers:
=== INPUT ===
const message = '<QIP & JavaScript>';
=== OUTPUT ===
<span class="syntax-keyword">const</span> message ...
=== END ===
The fixture parser runs at Zig compile time. It splits the checked-in text into
five input/output pairs. An inline for then emits five unconditional calls to
the imported qip.must_render_exactly function. The parser is not present in
the compiled oracle.
For each call, the QIP host:
- reads the input and expected-output slices from the oracle's memory;
- copies the input into the highlighter's memory;
- calls the highlighter's
render(i32) -> i64 export;
- reads the returned pointer and byte length; and
- compares the returned bytes with the expected HTML.
The ordinal identifies a failed case. The host reports its input, expected
output, and actual output. The oracle returns 5, and the host checks that it
observed exactly five calls.
Run the contract with:
qip comply \
components/text/javascript/js-syntax-highlight-html.wasm \
--with compliance/syntax-highlight-javascript-semantic.comply.wasm \
--straight-line-oracles
--straight-line-oracles inspects the compiled oracle. It permits constants,
direct oracle calls, dropped return values, and the final return. It rejects
branches, loops, helper calls, indirect calls, and memory instructions. This
check prevents a fixture oracle from conditionally skipping a case. It does
not restrict the highlighter implementation.
Run the larger Shiki comparison and benchmark with:
make -C benchmarks/syntax-highlight-comparison install
make -C benchmarks/syntax-highlight-comparison compare
make -C benchmarks/syntax-highlight-comparison benchmark
Use JSON output to inspect confusion matrices and per-class precision and
recall:
cd benchmarks/syntax-highlight-comparison
npm run compare -- --json react-development
Output classes #
The component uses the same nine-class vocabulary as gpu-lexer's comparison,
but not its token decisions. Plain text has no element. The eight highlighted
types use a syntax- prefix to avoid generic application class names.
| Token | HTML class |
| Comment | syntax-comment |
| String or regular expression | syntax-string |
| Number | syntax-number |
| Keyword | syntax-keyword |
| Type | syntax-type |
| Function | syntax-function |
| Constant | syntax-constant |
| Operator | syntax-operator |
| Plain text | No element |
These are not Shiki CSS classes. Shiki normally returns TextMate scopes,
tokens, and theme colors. The component's smaller vocabulary keeps presentation
separate from token meaning.
The comparison normalizes Shiki scopes in this order:
| Shiki TextMate scope | Common token |
comment* | Comment |
string* or a scope that contains regexp | String |
constant.numeric* | Number |
keyword.operator* | Operator |
entity.name.function* or support.function* | Function |
| Type, class, interface, enum, or supported class scopes | Type |
| Language constants and language variables | Constant |
Other keyword* or storage* scopes | Keyword |
| All other scopes | Plain text |
The remaining differences come from context that a small lexer does not fully
parse and from formatting-sensitive TextMate scopes. The component does not add
a special case only to imitate an unusual scope decision. A new rule must
describe useful JavaScript behavior and have a reduced Compliance case.
When to use another highlighter #
Use this QIP component for fast JavaScript highlighting, compact WebAssembly,
and stable semantic classes. Use Shiki when exact TextMate scope coverage and
theme compatibility justify its cost. Use Starry Night when you need
GitHub-compatible pl-* classes or a HAST tree. Use Prism or Sugar High when
their CSS and browser integration fit your application.
The lexer uses deliberate heuristics. It is not a complete JavaScript parser,
and it does not support the language range of Shiki, Starry Night, or gpu-lexer.
Did the Compliance oracle help? #
Yes. Shiki found the disagreements, but the Compliance oracle made each fix
quick to verify. Once a disagreement became a small fixture, we could run its
exact input and output check after every lexer change. This caught regressions
without running Shiki or processing the four large library files again.
The five-case Compliance command took a mean of 15.82 ms across 20 local runs.
The median was 15.51 ms. This time includes CLI startup, Wasm validation,
--straight-line-oracles validation, both module instances, and all five exact
output comparisons. It does not include rebuilding the Wasm files.
The two layers have different jobs. Use the slower Shiki comparison to discover
new classes of disagreement. Reduce each useful example and add it to the
Compliance fixture. The 16 ms Compliance check then becomes the normal inner
loop.