Yet Another LaTeX Tokeniser - a zero-dependency streaming LaTeX tokeniser built for LLM chat output.
yalt turns a stream of text into a stream of text and math events
so you can hand the math straight to KaTeX, Temml, or MathJax while
the rest of the content keeps flowing through your markdown pipeline.
"Let $x^2 + y^2 = r^2$ be the equation."
│
▼
{ type: 'text', value: 'Let ' }
{ type: 'math', value: 'x^2 + y^2 = r^2',
raw: '$x^2 + y^2 = r^2$',
display: 'inline',
delimiter: 'dollar' }
{ type: 'text', value: ' be the equation.' }
- Streaming first. Push-based
write(chunk)/end(). Chunk boundaries can fall anywhere - even mid-delimiter (\be+gin{align}) - and yalt still emits the right events.
- All common delimiters.
$…$,$$…$$,\(…\),\[…\], and\begin{env}…\end{env}for the AMS math family. - Progressive mode. Optional
mathStart/mathAppend/mathEndevents so a chat UI can render partial TeX as it arrives. - Currency-aware.
$100 dollarsis not math.Let $x$ be 5 and $10 is a pricecorrectly extracts justx. - Code-fence aware. Math inside
```and`is left alone. - Zero runtime dependencies, under 10 KB, renderer-agnostic.
- Linear scaling.
O(n)in total input regardless of chunk granularity. SeePOSITIONING.mdfor benchmarks.
npm install @benjamin_r/yaltNode 18+. ESM and CJS builds are both shipped.
Every frontier LLM uses different LaTeX delimiters:
| LLM | Inline | Display |
|---|---|---|
| ChatGPT | \(a \neq 0\) |
\[ax^2 + bx + c = 0\] |
| Gemini | $a$, $b^2 - 4ac$ |
$$x = \frac{-b \pm …}{2a}$$ |
| Claude | $a$ |
$$x = \frac{-b \pm …}{2a}$$ |
A chat UI has to handle all of them. The mainstream rendering stacks only handle dollar signs:
| Stack | $ |
$$ |
\(…\) |
\[…\] |
\begin{env} |
Streams? |
|---|---|---|---|---|---|---|
| react-markdown + remark-math | Yes | Yes | No | No | No | No, full re-render |
| Streamdown (Vercel AI chatbot) | Opt-in | Yes | No (#194) | No | Partial | Yes |
| markdown-it + texmath | Yes | Yes | Opt-in* | Opt-in* | Opt-in* | No, full re-parse |
| yalt | Yes | Yes | Yes | Yes | Yes | Yes, push-based |
* texmath supports these with non-default delimiters config, but
markdown-it still consumes \ as an escape in inline contexts,
so \(a\) can become (a) before texmath sees it.
When ChatGPT sends \(a \neq 0\), Markdown treats the \ as an
escape character and strips it before the math plugin ever sees it.
The user gets bare (a ≠ 0) instead of rendered math. This conflict
between markdown escaping and LaTeX delimiters has been reported across
many LLM chat UIs - some have shipped fixes, others remain open:
- streamdown#194 (closed, workaround via preprocessing)
- react-markdown#785 (closed as by-design)
- chat-ui#1097 (fixed in a later release)
- streamlit#9272 (open feature request)
Each fix is project-specific. The underlying tension - markdown treats
\ as an escape, LaTeX uses it for delimiters - is structural.
The standard workaround is regex preprocessing to convert \(…\) to
$…$ before the markdown parser, but this is fragile during streaming
because \ and ( can arrive in different chunks.
yalt sits below the markdown parser and handles math extraction in a single streaming pass - no regex preprocessing, no re-parsing:
import { parse } from '@benjamin_r/yalt';
const llmResponse =
'The Pythagorean theorem states \\(a^2 + b^2 = c^2\\).\n' +
'Given sides $a$ and $b$, the hypotenuse $c$ satisfies:\n' +
'\\begin{equation}\nc = \\sqrt{a^2 + b^2}\n\\end{equation}\n' +
'The textbook costs $20 and shipping is $5 extra.';
parse(llmResponse);
// → text, \(a^2 + b^2 = c^2\), text, $a$, text, $b$, text, $c$,
// text, \begin{equation}…\end{equation}, text (currency untouched)This is the scenario yalt exists for. Your LLM streams tokens back,
yalt turns them into text and math events, and you render text as
text and math through KaTeX - all in a single linear pass, without
ever re-parsing the buffer.
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import katex from 'katex';
import { parseStream, toRenderInput } from '@benjamin_r/yalt';
import 'katex/dist/katex.min.css';
// 1. Kick off the LLM stream. `textStream` is already an
// AsyncIterable<string>, so it drops straight into parseStream.
const { textStream } = streamText({
model: openai('gpt-4o'),
prompt: 'Derive the quadratic formula with intermediate steps.',
});
// 2. Walk the event stream. Text becomes a text node, math becomes
// rendered KaTeX HTML. Each event is appended once and never
// revisited - no re-render, no re-parse.
const container = document.querySelector('#chat-message')!;
for await (const event of parseStream(textStream)) {
if (event.type === 'text') {
container.append(event.value);
} else if (event.type === 'math') {
// `toRenderInput` returns `{ tex, displayMode }` - the shape every
// math renderer expects. For `\begin{name}…\end{name}` math it
// also restores the wrapper yalt strips from `value`, so you
// never have to remember to rewrap environment bodies yourself.
const { tex, displayMode } = toRenderInput(event);
const wrapper = document.createElement(displayMode ? 'div' : 'span');
wrapper.innerHTML = katex.renderToString(tex, {
displayMode,
throwOnError: false,
});
container.append(wrapper);
}
}Two moving parts: parseStream to tokenise the stream, and
toRenderInput to hand each math event to KaTeX. See
examples/react-ai-sdk.tsx and
examples/react-streaming.tsx for
complete React components using this pattern.
If your SDK yields chunk objects rather than strings (OpenAI,
Anthropic raw iteration, etc.), filter into a string generator and
feed that to parseStream:
import OpenAI from 'openai';
import { parseStream } from '@benjamin_r/yalt';
const stream = await new OpenAI().chat.completions.create({
model: 'gpt-4o',
stream: true,
messages: [{ role: 'user', content: '…' }],
});
async function* textDeltas() {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) yield delta;
}
}
for await (const event of parseStream(textDeltas())) {
// same text / math handling as above
}The Vercel AI SDK, LangChain JS, Anthropic SDK (.stream().on('text')),
and any other library exposing chunks as strings or extractable text
deltas all plug in the same way. See
examples/with-yalt.ts for a runnable
Anthropic SDK example.
For non-streaming inputs the same toRenderInput works with parse:
import { parse, toRenderInput } from '@benjamin_r/yalt';
import katex from 'katex';
const html = parse(llmResponse)
.map(event => {
if (event.type === 'text') return escapeHtml(event.value);
const { tex, displayMode } = toRenderInput(event);
return katex.renderToString(tex, { displayMode, throwOnError: false });
})
.join('');To use MathJax or Temml instead of KaTeX, see
examples/renderers.ts.
If something else is handing you chunks and you cannot iterate:
import { YaltTokeniser } from '@benjamin_r/yalt';
const tokeniser = new YaltTokeniser();
const events = [
...tokeniser.write('Let $x +'),
...tokeniser.write(' y = z$ done.'),
...tokeniser.end(), // flush held-back text
];Everything above emits one math event per expression, on close -
the whole math span appears in the DOM only after its closing
delimiter arrives. For long block equations, chat UIs often prefer to
render partial TeX as tokens stream in. Opt in with
{ progressive: true } and you get mathStart → mathAppend* →
mathEnd events instead:
import { YaltTokeniser, toRenderInput, type MathStartEvent } from '@benjamin_r/yalt';
import katex from 'katex';
const tokeniser = new YaltTokeniser({ progressive: true });
let start: MathStartEvent | null = null;
let buffer = '';
let placeholder: HTMLElement | null = null;
for await (const chunk of textStream) {
for (const event of tokeniser.write(chunk)) {
if (event.type === 'text') {
container.append(event.value);
} else if (event.type === 'mathStart') {
start = event;
buffer = '';
placeholder = document.createElement(
event.display === 'block' ? 'div' : 'span',
);
container.append(placeholder);
} else if (event.type === 'mathAppend' && start && placeholder) {
buffer += event.value;
// Re-wrap the partial buffer with the delimiter info from
// `start` so `\begin{align}` bodies are rendered as real align
// environments while tokens are still arriving. `throwOnError:
// false` makes KaTeX emit an error span for partial input
// instead of throwing - exactly what you want mid-stream.
const { tex, displayMode } = toRenderInput({ ...start, value: buffer });
placeholder.innerHTML = katex.renderToString(tex, {
displayMode,
throwOnError: false,
});
} else if (event.type === 'mathEnd' && !event.aborted && placeholder) {
const { tex, displayMode } = toRenderInput(event);
placeholder.innerHTML = katex.renderToString(tex, {
displayMode,
throwOnError: true,
});
}
}
}mathEnd.aborted is true if the stream ended mid-math, so the UI
can decide whether to keep, error, or drop the partial content. See
examples/progressive.ts for a runnable
CLI demo of this mode.
| Input | display |
delimiter |
environment |
|---|---|---|---|
$x$ |
inline |
dollar |
- |
$$x$$ |
block |
dollar |
- |
\(x\) |
inline |
paren |
- |
\[x\] |
block |
bracket |
- |
\begin{align}x\end{align} |
block |
environment |
'align' |
The default environment whitelist covers every AMS math environment
current frontier LLMs emit (equation, align, gather, multline,
and their starred variants, plus CD, eqnarray, etc.). Override
with environmentWhitelist.
interface YaltOptions {
dollar?: boolean; // `$…$` / `$$…$$`. default: true
paren?: boolean; // `\(…\)`. default: true
bracket?: boolean; // `\[…\]`. default: true
environment?: boolean; // `\begin{env}…\end{env}`. default: true
environmentWhitelist?: readonly string[];
skipCode?: boolean; // skip fenced/inline code. default: true
dollarCurrencyHeuristic?: boolean; // `$100` stays text. default: true
progressive?: boolean; // mathStart/Append/End. default: false
}parse(input, options?) → YaltEvent[]- one-shot parse.parseStream(source, options?) → AsyncIterable<YaltEvent>- consume anyAsyncIterable<string>and yield events as they commit.new YaltTokeniser(options?)- push-based tokeniser.write(chunk)- feed a chunk, get every event committable so far.end()- flush. Idempotent. Callingwriteafterwards throws.reset()- clear state to reuse the instance on a new stream.
toRenderInput(event) → { tex, displayMode }- renderer-agnostic helper that turns a math event into the two fields every math renderer expects. Restores\begin{name}…\end{name}wrappers for environment math. AcceptsMathEvent,MathEndEvent, or{ ...mathStartEvent, value: partialBuffer }for progressive partial renders.
Event shapes live in src/types.ts.
The examples/ directory has drop-in integration code for common stacks:
| Example | Framework | Renderer | Pattern |
|---|---|---|---|
react-ai-sdk.tsx |
React + Vercel AI SDK | KaTeX | useChat + one-shot parse per message |
react-streaming.tsx |
React (custom hook) | KaTeX | parseStream for true streaming |
vue-streaming.ts |
Vue 3 composable | KaTeX | Reactive events ref + parseStream |
renderers.ts |
Any | KaTeX, MathJax, Temml | toRenderInput adapter for each renderer |
with-yalt.ts |
Node.js CLI | - | Anthropic SDK streaming |
without-yalt.ts |
Node.js CLI | - | The problem: raw text + naive regex |
progressive.ts |
Node.js CLI | - | mathStart / mathAppend / mathEnd |
All renderers accept the same { tex, displayMode } shape that
toRenderInput returns, so switching renderers is a one-line change.
The examples/chat/ directory is a self-contained browser chat UI that
sends a single prompt to Claude and renders the response three ways
side by side:
| Column | What it does |
|---|---|
| Raw output | Plain text - LaTeX delimiters visible as \(, \[, \begin{align*} |
| markdown-it + texmath | Re-parses full buffer each chunk. Renders $…$ but misses \(…\) and \begin{env} |
| yalt + markdown-it + KaTeX | yalt splits math from text in one streaming pass. Text → markdown-it, math → KaTeX. Progressive rendering |
# Add your Anthropic API key to .env.local
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env.local
npm run example:chat
# → open http://localhost:3000- Render math. Pair it with KaTeX, Temml, or MathJax.
- Parse markdown. Pair it with Streamdown, react-markdown, marked. yalt knows how to skip code fences but doesn't emit code events.
- Normalise or sanitise TeX. Output is byte-for-byte the same substring that appeared in the input.
See POSITIONING.md for where yalt sits in the
stack and how it compares to the alternatives.
npm install
npm test # 743 tests
npm run typecheck
npm run build
npm run bench
npm run example:chat # browser demo (needs ANTHROPIC_API_KEY in .env.local)MIT