npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@benjamin_r/yalt

v0.1.0

Published

Yet Another LaTeX Tokeniser - a zero-dependency streaming LaTeX tokeniser built for LLM text output.

Readme

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