Skip to content

benjaminr/yalt

Repository files navigation

yalt

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.'  }

Why yalt

  • Streaming first. Push-based write(chunk) / end(). Chunk boundaries can fall anywhere - even mid-delimiter (\be + gin{al
    • ign}) - 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 / mathEnd events so a chat UI can render partial TeX as it arrives.
  • Currency-aware. $100 dollars is not math. Let $x$ be 5 and $10 is a price correctly extracts just x.
  • 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. See POSITIONING.md for benchmarks.

Install

npm install @benjamin_r/yalt

Node 18+. ESM and CJS builds are both shipped.

The problem yalt solves

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:

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)

The full stack: streaming AI chat → KaTeX → DOM

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.

Non-string upstreams

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.

One-shot (no streaming)

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.

Manual push API

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
];

Progressive rendering

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 mathStartmathAppend*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.

Supported delimiters

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.

Options

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
}

API

  • parse(input, options?) → YaltEvent[] - one-shot parse.
  • parseStream(source, options?) → AsyncIterable<YaltEvent> - consume any AsyncIterable<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. Calling write afterwards 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. Accepts MathEvent, MathEndEvent, or { ...mathStartEvent, value: partialBuffer } for progressive partial renders.

Event shapes live in src/types.ts.

Framework and renderer examples

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.

Live demo

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

What yalt deliberately doesn't do

  • 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.

Development

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)

Licence

MIT

About

Yet Another LaTeX Tokeniser - streaming LaTeX tokeniser for LLM chat output

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors