@tanstack/highlight
Version:
Tiny class-based syntax highlighting for documentation.
262 lines (189 loc) • 9.22 kB
Markdown
<div align="center">
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://tanstack.com/api/readme/highlight.png?theme=dark"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://tanstack.com/api/readme/highlight.png"
/>
<img
src="https://tanstack.com/api/readme/highlight.png"
alt="TanStack Highlight"
width="900"
/>
</picture>
</div>
# TanStack Highlight
Tiny, synchronous syntax highlighting for blogs and documentation.
- Import only the languages you use.
- Run the same code during SSR and in the browser.
- Emit one small, class-based HTML tree for every theme.
- Highlight embedded `<script>`, `<style>`, and Markdown fence regions when their languages are registered.
- Decorate lines and character ranges without a transformer framework.
This is not an editor parser or a TextMate engine. It is a deliberately small docs highlighter.
## Documentation
- [Overview](docs/overview.md)
- [Migrating to v1 and compatibility policy](docs/guides/migrating-to-v1.md)
- [Installation](docs/installation.md)
- [Quick Start](docs/quick-start.md)
- [Comparison](docs/comparison.md)
- [Language Support](docs/language-support.md)
- [Octane Integration](docs/guides/octane.md)
- [Guides](docs/guides/language-registration.md)
- [API Reference](docs/reference/index.md)
## Install
```sh
pnpm add @tanstack/highlight
```
If you use an AI agent, run `npx @tanstack/intent@latest install`.
## Selective Languages
```ts
import { createHighlighter } from '@tanstack/highlight/core'
import { css } from '@tanstack/highlight/languages/css'
import { html } from '@tanstack/highlight/languages/html'
import { ts } from '@tanstack/highlight/languages/ts'
import { tsx } from '@tanstack/highlight/languages/tsx'
export const highlighter = createHighlighter({
languages: [css, html, ts, tsx],
})
const result = highlighter.highlight(`const node = <Button />`, {
lang: 'tsx',
})
```
`html` does not import script or style languages. Registering `js`, `ts`, or `css` enables delegation for matching embedded regions without increasing the standalone HTML module.
For convenience, the root entry contains every shipped language:
```ts
import { highlight } from '@tanstack/highlight'
const result = highlight(`const value = 'docs'`, { lang: 'ts' })
```
Unknown languages fall back to escaped plaintext.
## SSR And Client
Create one highlighter in an isomorphic module and import that module from both rendering paths:
```ts
// highlight.ts
import { createHighlighter } from '@tanstack/highlight/core'
import { css } from '@tanstack/highlight/languages/css'
import { html } from '@tanstack/highlight/languages/html'
import { js } from '@tanstack/highlight/languages/js'
import { ts } from '@tanstack/highlight/languages/ts'
import { tsx } from '@tanstack/highlight/languages/tsx'
export const highlighter = createHighlighter({
languages: [css, html, js, ts, tsx],
})
```
The API is synchronous and deterministic. Server output and hydrated client output are identical when they use the same registrations and options.
## Markdown Pipelines
Adapters take an explicit highlighter, so importing one never pulls in the all-language build.
TanStack Markdown owns its `<pre><code>` containers. Use the dedicated adapter so Highlight returns only escaped inner token markup:
```ts
import { createTanStackMarkdownHighlighter } from '@tanstack/highlight/markdown'
import { highlighter } from './highlight'
export const highlightMarkdownCode =
createTanStackMarkdownHighlighter(highlighter)
```
```ts
import { remarkHighlightCodeBlocks } from '@tanstack/highlight/remark'
import { highlighter } from './highlight'
const plugin = remarkHighlightCodeBlocks({ highlighter })
```
```ts
import { rehypeHighlightCodeBlocks } from '@tanstack/highlight/rehype'
import { highlighter } from './highlight'
const plugin = rehypeHighlightCodeBlocks({ highlighter })
```
The remark adapter emits standard HAST data rather than raw HTML. The rehype adapter replaces `<pre><code class="language-*">` nodes and is idempotent.
## Octane
The isolated Octane entry supports direct components and `@octanejs/mdx` without a framework dependency:
```ts
import { octaneMdx } from '@octanejs/mdx/vite'
import { createOctaneMdxHighlight } from '@tanstack/highlight/octane'
import { highlighter } from './highlight'
octaneMdx({
rehypePlugins: [createOctaneMdxHighlight({ highlighter })],
})
```
The plugin is synchronous, so it works with both `compileMdx()` and `compileMdxSync()`. Direct Octane components can use `createHighlightedCodeBlockProps()` from the same entry to receive an escaped `dangerouslySetInnerHTML` payload.
Fence metadata supports titles, line numbers, and common line annotations:
````md
```tsx title="App.tsx" {2,4-6} ins={8} del={9} error={11} lineNumbers
```
````
Supported annotation names are `highlight`, `ins`, `del`, `focus`, `error`, and `warning`.
## Decorations
Programmatic decorations can target one or more lines or an exact character range:
```ts
const code = `const first = 1\nconst second = 2`
const start = code.indexOf('second')
highlighter.highlight(code, {
lang: 'ts',
lineNumbers: true,
decorations: [
{ lines: 2, className: 'is-focused', data: { kind: 'focus' } },
{ range: [start, start + 6], className: 'is-error' },
],
})
```
Overlapping range decorations are split into valid nested spans in declaration order. Decoration data is escaped and emitted as `data-*` attributes.
## Themes
Themes are isolated imports. The root and language entries contain no theme code.
```ts
import { createThemeCss } from '@tanstack/highlight/theme'
import { draculaTheme } from '@tanstack/highlight/themes/dracula'
import { githubLightTheme } from '@tanstack/highlight/themes/github-light'
const css = createThemeCss({
light: githubLightTheme,
dark: draculaTheme,
darkSelector: '.dark',
})
```
For TanStack Markdown, set `codeBlockSelector` to its `pre.tm-code` wrapper and `lineNumbersSelector` to `.tm-code--line-numbers`. See the [Markdown pipeline guide](docs/guides/markdown-pipelines.md#tanstack-markdown).
Available themes: Aurora X, Dracula, GitHub Dark, GitHub Light, Gruvbox Dark, Gruvbox Light, Monokai, Nord, One Dark Pro, Solarized Dark, and Solarized Light.
## Languages
`apache`, `cmake`, `cpp`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `php`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`.
Each language is available from `@tanstack/highlight/languages/<name>`. The aggregate `@tanstack/highlight/languages` entry can tree-shake, while direct subpaths make isolation explicit. Importing only core helpers from the root entry also removes unused language registrations in a compatible bundler.
## Output Contract
- One `<pre><code>` tree per block.
- No inline styles or colors.
- Stable `th-*` semantic token classes.
- Source text is preserved exactly by tokenization.
- HTML and decoration attributes are escaped.
- Light/dark switching is CSS-only and does not duplicate markup.
## Size And Speed
Local browser bundles, minified with esbuild and compressed independently. KB uses 1,000 bytes:
| Registration | Minified | Gzip | Brotli |
| --- | ---: | ---: | ---: |
| Core, no languages | 3.84 KB | 1.82 KB | 1.66 KB |
| Core + TSX | 10.11 KB | 4.26 KB | 3.90 KB |
| Octane MDX + TypeScript | 13.86 KB | 5.59 KB | 5.17 KB |
| Nine-language docs set | 16.04 KB | 6.20 KB | 5.66 KB |
| All 30 languages | 30.72 KB | 10.77 KB | 9.79 KB |
The following comparison was measured before the 1.0 property-context correction. Re-run the comparison commands below for current timings.
On 80 real JavaScript/TypeScript/JSX/TSX TanStack docs fixtures repeated across 5,040 blocks, using the median of three runs after warmup:
| | Bundle gzip | Highlight time | Generated HTML |
| --- | ---: | ---: | ---: |
| TanStack Highlight | 4.11 KB | 78 ms | 6.7 MiB |
| Sugar High 1.2.1 | 3.28 KB | 377 ms | 44.8 MiB |
On all 334 committed docs fixtures, warmed highlighting took 4.6 ms with TanStack Highlight and 182 ms with Shiki 4.3.1. Shiki also took 22 ms to initialize and 47 ms to load languages, and produced 3.4x more HTML. The comparison falls back to plaintext for EJS, ENV, and TSRX in Shiki. These local measurements do not imply equivalent grammar depth.
Reproduce them with:
```sh
pnpm run size
pnpm run bench
pnpm run compare:sugar-high
pnpm run compare:shiki
```
## Non-Goals
- Automatic language detection
- Editor or incremental parsing
- Semantic language-service tokens
- TextMate or VS Code theme compatibility
- Hundreds of languages
- Exact parity with language compilers or IDEs
The quality bar is common, valid code found in blogs and documentation. Every parser fix should add a focused regression fixture without turning the package into a general grammar runtime.
## Development
```sh
pnpm install
pnpm run verify
```
`verify` checks types, package exports, publint, 334 real docs fixtures, focused parser regressions, bundle budgets, and a roughly 10,000-block throughput budget.