Syntax Highlighting
@humanspeak/svelte-markdown ships one engine-agnostic code renderer and two opt-in engines:
| Subpath | What it gives you | Pulls in |
|---|---|---|
@humanspeak/svelte-markdown/extensions/highlight | HighlightedCode renderer, CodeHighlighter type, HIGHLIGHT_CONTEXT_KEY, setCodeHighlighter | Nothing — no engine |
@humanspeak/svelte-markdown/extensions/shiki | createShikiHighlighter (TextMate grammars, inline theme colors) | shiki |
@humanspeak/svelte-markdown/extensions/tanstack-highlight | createTanstackHighlighter (hand-written scanners, semantic th-* classes) | @tanstack/highlight |
You pick an engine at setup time. The renderer, the context key, and the singleton are the same regardless of engine, so switching is a one-line factory change and two engines can coexist on one page. Both engines are synchronous, so streaming stays enabled. Neither is re-exported from the extensions barrel, so consumers who skip highlighting never need either peer installed.
The TanStack adapter supports Highlight 0.1 and 1.x. Upgrading to 1.0 requires no changes to imports, factory options, or theme setup; JavaScript and TypeScript property names that are keywords may receive corrected colors.
Why It’s a Renderer, Not a Marked Extension
KaTeX, Mermaid, alerts, and footnotes are marked extensions: they add custom token types via the extensions prop. Highlighting is different. A code fence is already a first-class code token, so there is nothing new to tokenize — the work is purely at render time. HighlightedCode is therefore a drop-in replacement for the built-in code renderer with the same Props shape (lang, text):
<SvelteMarkdown {source} renderers={{ code: HighlightedCode }} /><SvelteMarkdown {source} renderers={{ code: HighlightedCode }} />This distinction matters for streaming. An async marked extension (one that returns a promise from walkTokens) forces SvelteMarkdown to disable streaming — the incremental parser can’t reconcile a tail window against pending async work. Both engines here return a string immediately with no top-level await, so the code renderer never trips the hasAsyncExtension guard and streaming is never silently turned off.
The CodeHighlighter Contract
Every engine factory returns the same two-method object:
interface CodeHighlighter {
/** Synchronous. Must never throw — degrade to an escaped fallback instead. */
highlight(code: string, lang: string): string
/** Whether `lang` (name or alias) is registered. */
hasLang(lang: string): boolean
}interface CodeHighlighter {
/** Synchronous. Must never throw — degrade to an escaped fallback instead. */
highlight(code: string, lang: string): string
/** Whether `lang` (name or alias) is registered. */
hasLang(lang: string): boolean
}That is the whole integration surface. You can implement it yourself to wrap any other highlighter (Prism, highlight.js, a server round-trip you cache) and HighlightedCode will use it unchanged. The one hard rule is in the comment: highlight sits on the streaming render path, so it must be synchronous and must never throw on a half-streamed fence.
Choosing an Engine
| Shiki | TanStack Highlight | |
|---|---|---|
| Accuracy | TextMate grammars (the ones VS Code runs) — best for JSX/TSX, embedded languages, unusual syntax | Hand-written scanners for 25 languages — good for docs and chat, weaker on exotic code |
Bundle (core + ts, js, json) | 87 KB gzip (534 KB minified) with one theme | 4.2 KB gzip (10 KB minified) |
| Output | Inline style="color:…" per token | Semantic th-keyword, th-string, … classes; no inline colors |
| Theming | Themes are data; light/dark needs dual output or re-highlight | Themes are CSS variables via createThemeCss; light/dark is a CSS toggle |
| Languages | 200+ grammars | 25 (ts, tsx, js, jsx, json, css, html, svelte, vue, python, go, sql, shell, yaml, markdown, …) |
| Streaming cost on an open fence | Re-highlights the whole block per flush; ~1–2 ms per line with the JS engine | Re-highlights the whole block per flush; well under a millisecond for typical blocks |
| Maturity | Stable | 0.1.x — the API can still move; pin your version |
| Unknown language | Escaped <pre class="shiki-fallback"> | Engine plaintext (<pre class="th-code th-code--plaintext">) so it still picks up your theme; or the escaped fallback with plaintextFallback: false |
Sizes above were measured with esbuild (--bundle --minify, gzip -9) against shiki@4.4 and @tanstack/highlight@0.1.0. See the live side-by-side for per-engine highlight timings while streaming.
Rule of thumb: documentation sites that want exact editor colors pick Shiki. Chat and agent UIs that stream a lot of code and care about bundle size pick TanStack Highlight. Both are streaming-safe; the tradeoff is fidelity versus weight.
Install
Each engine is an optional peer dependency — install the one you use:
pnpm add shiki # for createShikiHighlighter
pnpm add @tanstack/highlight # for createTanstackHighlighterpnpm add shiki # for createShikiHighlighter
pnpm add @tanstack/highlight # for createTanstackHighlighterWire It Up — TanStack Highlight
Import only the languages you need (each is a separate ESM module), build a highlighter, register it, and map HighlightedCode to the code renderer. TanStack emits semantic classes only, so also emit a theme stylesheet:
<script lang="ts">
import SvelteMarkdown from '@humanspeak/svelte-markdown'
import {
createTanstackHighlighter,
HighlightedCode,
setCodeHighlighter
} from '@humanspeak/svelte-markdown/extensions/tanstack-highlight'
import { ts } from '@tanstack/highlight/languages/ts'
import { json } from '@tanstack/highlight/languages/json'
import { createThemeCss } from '@tanstack/highlight/theme'
import githubDark from '@tanstack/highlight/themes/github-dark'
import githubLight from '@tanstack/highlight/themes/github-light'
// Register once — every HighlightedCode instance resolves this singleton.
setCodeHighlighter(createTanstackHighlighter({ languages: [ts, json] }))
// Colors live in CSS variables; `darkSelector` must match how your app
// toggles dark mode (`html.dark`, `.dark`, `[data-theme="dark"]`, …).
const themeCss = createThemeCss({
light: githubLight,
dark: githubDark,
darkSelector: 'html.dark'
})
const source = `
\`\`\`ts
const answer: number = 42
console.log(answer)
\`\`\`
`
</script>
<svelte:head>
{@html `<style>${themeCss}</style>`}
</svelte:head>
<SvelteMarkdown {source} renderers={{ code: HighlightedCode }} /><script lang="ts">
import SvelteMarkdown from '@humanspeak/svelte-markdown'
import {
createTanstackHighlighter,
HighlightedCode,
setCodeHighlighter
} from '@humanspeak/svelte-markdown/extensions/tanstack-highlight'
import { ts } from '@tanstack/highlight/languages/ts'
import { json } from '@tanstack/highlight/languages/json'
import { createThemeCss } from '@tanstack/highlight/theme'
import githubDark from '@tanstack/highlight/themes/github-dark'
import githubLight from '@tanstack/highlight/themes/github-light'
// Register once — every HighlightedCode instance resolves this singleton.
setCodeHighlighter(createTanstackHighlighter({ languages: [ts, json] }))
// Colors live in CSS variables; `darkSelector` must match how your app
// toggles dark mode (`html.dark`, `.dark`, `[data-theme="dark"]`, …).
const themeCss = createThemeCss({
light: githubLight,
dark: githubDark,
darkSelector: 'html.dark'
})
const source = `
\`\`\`ts
const answer: number = 42
console.log(answer)
\`\`\`
`
</script>
<svelte:head>
{@html `<style>${themeCss}</style>`}
</svelte:head>
<SvelteMarkdown {source} renderers={{ code: HighlightedCode }} />Language aliases are resolved by the engine — registering ts also matches typescript. Output looks like:
<pre class="th-code th-code--ts" data-language="ts"><code><span class="th-token th-keyword">const</span> answer: <span class="th-token th-type">number</span> = <span class="th-token th-number">42</span></code></pre><pre class="th-code th-code--ts" data-language="ts"><code><span class="th-token th-keyword">const</span> answer: <span class="th-token th-type">number</span> = <span class="th-token th-number">42</span></code></pre>Style the block background with --th-background and the base text color with --th-token; both come from the theme stylesheet.
Unregistered Languages
By default an unregistered lang renders through the engine’s own plaintext path, so the block keeps the th-code wrapper and your theme’s background. If you would rather get the shared escaped fallback (a bare <pre class="th-code th-code--fallback" data-lang="…">), pass plaintextFallback: false:
createTanstackHighlighter({ languages: [ts], plaintextFallback: false })createTanstackHighlighter({ languages: [ts], plaintextFallback: false })Either way the untrusted lang never reaches markup unescaped: the engine normalizes unknown values to plaintext, and the fallback escapes it into data-lang.
Wire It Up — Shiki
Import only the languages and themes you need, build a highlighter, register it, and map the renderer:
<script lang="ts">
import SvelteMarkdown from '@humanspeak/svelte-markdown'
import {
createShikiHighlighter,
HighlightedCode,
setCodeHighlighter
} from '@humanspeak/svelte-markdown/extensions/shiki'
import js from 'shiki/langs/javascript.mjs'
import ts from 'shiki/langs/typescript.mjs'
import githubDark from 'shiki/themes/github-dark.mjs'
setCodeHighlighter(
createShikiHighlighter({ langs: [js, ts], themes: [githubDark] })
)
const source = `
\`\`\`ts
const answer: number = 42
console.log(answer)
\`\`\`
`
</script>
<SvelteMarkdown {source} renderers={{ code: HighlightedCode }} /><script lang="ts">
import SvelteMarkdown from '@humanspeak/svelte-markdown'
import {
createShikiHighlighter,
HighlightedCode,
setCodeHighlighter
} from '@humanspeak/svelte-markdown/extensions/shiki'
import js from 'shiki/langs/javascript.mjs'
import ts from 'shiki/langs/typescript.mjs'
import githubDark from 'shiki/themes/github-dark.mjs'
setCodeHighlighter(
createShikiHighlighter({ langs: [js, ts], themes: [githubDark] })
)
const source = `
\`\`\`ts
const answer: number = 42
console.log(answer)
\`\`\`
`
</script>
<SvelteMarkdown {source} renderers={{ code: HighlightedCode }} />Language aliases come for free — registering javascript also matches js, cjs, and mjs. Shiki inlines theme colors per token, so no stylesheet is needed; pass every theme you want available in themes and pick the active one with theme:
createShikiHighlighter({
langs: [ts],
themes: [githubDark, githubLight],
theme: 'github-light'
})createShikiHighlighter({
langs: [ts],
themes: [githubDark, githubLight],
theme: 'github-light'
})Backward-Compatible Names
The extensions/shiki subpath still exports ShikiCode, SHIKI_CONTEXT_KEY, setShikiHighlighter, getShikiHighlighter, and the ShikiHighlighter type. They are aliases: ShikiCode is HighlightedCode, SHIKI_CONTEXT_KEY is the same symbol as HIGHLIGHT_CONTEXT_KEY, and the setters write the same singleton. Existing code keeps working; new code should use the extensions/highlight names. One visible change: when no highlighter is configured at all, the renderer’s fallback <pre> now carries the engine-agnostic class highlight-fallback instead of shiki-fallback. The Shiki factory’s own unregistered-language fallback is unchanged (shiki-fallback).
Choosing How to Inject the Highlighter
HighlightedCode resolves its highlighter in priority order:
- An explicit
highlighterprop — only reachable if you wrapHighlightedCodein your own component. Use it as an escape hatch. - A Svelte context set under
HIGHLIGHT_CONTEXT_KEYby an ancestor of<SvelteMarkdown>. Use this when you need per-subtree engines or themes, or SSR request isolation (a per-request highlighter on the server). - The module singleton set via
setCodeHighlighter. The simplest default for an app with a single engine and theme.
<script lang="ts">
import { setContext } from 'svelte'
import { HIGHLIGHT_CONTEXT_KEY } from '@humanspeak/svelte-markdown/extensions/highlight'
import { createTanstackHighlighter } from '@humanspeak/svelte-markdown/extensions/tanstack-highlight'
import { ts } from '@tanstack/highlight/languages/ts'
setContext(HIGHLIGHT_CONTEXT_KEY, createTanstackHighlighter({ languages: [ts] }))
</script><script lang="ts">
import { setContext } from 'svelte'
import { HIGHLIGHT_CONTEXT_KEY } from '@humanspeak/svelte-markdown/extensions/highlight'
import { createTanstackHighlighter } from '@humanspeak/svelte-markdown/extensions/tanstack-highlight'
import { ts } from '@tanstack/highlight/languages/ts'
setContext(HIGHLIGHT_CONTEXT_KEY, createTanstackHighlighter({ languages: [ts] }))
</script>Because both engines produce the same CodeHighlighter, the side-by-side demo runs Shiki in one subtree and TanStack in another with no singleton cross-talk — each pane just sets its own context.
Unregistered Languages and Failures
Highlighting must never throw mid-stream — an exception on a partially-streamed fence would tear down the render. So for an unregistered or empty language, and for any per-block highlighting failure, every factory degrades to an escaped fallback instead of throwing:
<pre class="shiki-fallback" data-lang="rust"><code>fn main() {}</code></pre><pre class="shiki-fallback" data-lang="rust"><code>fn main() {}</code></pre>The code text is HTML-escaped and the (untrusted) lang is emitted only as an escaped data-lang attribute — never interpolated as raw markup. Style .shiki-fallback, .th-code--fallback, and .highlight-fallback to match your registered-language blocks.
Streaming Behavior
html inside HighlightedCode is $derived purely from (text, lang) and the resolved highlighter, so a completed fence is never re-highlighted as later prose streams in. An open fence is re-highlighted on every flush because its text changes; that per-flush cost is where the two engines differ most. Watch the calls and last counters in the side-by-side demo: both stop climbing the moment a block closes.
Security — the {@html} Sink
HighlightedCode injects the highlighter’s HTML string via {@html}, the same sink KatexRenderer and MermaidRenderer already use. The trust model is the same, and slightly stronger:
- Code text is tokenized and escaped by the engine (Shiki emits
<, TanStack emits<), so user- or agent-supplied code content is never live markup. The fallback path independently escapes the code text. - The
lang(fenced info string) is untrusted — in this package’s headline agent/LLM-streaming use case it is attacker-influenced. Shiki uses it only as a lookup key; TanStack normalizes unknown values toplaintext; the fallback path escapes, never interpolates it. Adversarial strings like"><img src=x onerror=alert(1)>are covered by tests on every path.
Net: the {@html} sink only ever receives engine-generated or explicitly-escaped markup. Highlighting does not replace HTML sanitization; keep your usual security posture for the rest of the document.
Bundle Size — Why It’s Opt-in
Both engines are opt-in for the same reason: the core SvelteMarkdown bundle must stay engine-free, and it does — enforced by scripts/tree-shaking.mjs and covered on the Tree Shaking page.
- Importing
HighlightedCode(from any subpath) without building a highlighter pulls in nothing from either engine. - The cost lands only when you construct a highlighter. Shiki with the JS engine,
ts/js/json, and one theme is about 87 KB gzip; TanStack with the same three languages is about 4.2 KB gzip. - The engines never leak into each other: the Shiki factory bundles nothing from TanStack and vice versa.
- Import narrowly: every extra grammar, language, or theme you import is bundled. Register only what your content uses.
JS Engine vs. Oniguruma (Shiki only)
Shiki’s default pure-JavaScript regex engine has no WASM, which keeps SSR trivial (the sync core server-renders fully-highlighted markup with no asset or loader concerns) — the right default for most apps. Its tradeoff is per-block cost: highlighting is roughly linear at ~1.2–1.7 ms per line, so a large block being actively streamed can exceed a single frame. For highlight-heavy, client-rendered apps, Shiki’s oniguruma-WASM engine is ~2.5× faster per block and is still streaming-safe (the WASM loads once at setup; per-block highlighting stays synchronous) — at the cost of shipping a WASM asset and an async setup step. TanStack Highlight has no engine choice to make; it is already synchronous and dependency-free.
Related
- Highlight Engines Side-by-Side — stream one response through both engines with live timings
- Shiki Interactive Demo — Shiki with fallback and live editing
- Marked Extensions — KaTeX, Mermaid, alerts, and footnotes via the
extensionsprop - LLM Streaming — why synchronous rendering keeps streaming enabled
- Tree Shaking — how the core bundle stays engine-free
- Security — sanitization defaults and the
{@html}trust model