logo svelte /markdown v2.1.2

Syntax Highlighting

@humanspeak/svelte-markdown ships one engine-agnostic code renderer and two opt-in engines:

SubpathWhat it gives youPulls in
@humanspeak/svelte-markdown/extensions/highlightHighlightedCode renderer, CodeHighlighter type, HIGHLIGHT_CONTEXT_KEY, setCodeHighlighterNothing — no engine
@humanspeak/svelte-markdown/extensions/shikicreateShikiHighlighter (TextMate grammars, inline theme colors)shiki
@humanspeak/svelte-markdown/extensions/tanstack-highlightcreateTanstackHighlighter (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

ShikiTanStack Highlight
AccuracyTextMate grammars (the ones VS Code runs) — best for JSX/TSX, embedded languages, unusual syntaxHand-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 theme4.2 KB gzip (10 KB minified)
OutputInline style="color:…" per tokenSemantic th-keyword, th-string, … classes; no inline colors
ThemingThemes are data; light/dark needs dual output or re-highlightThemes are CSS variables via createThemeCss; light/dark is a CSS toggle
Languages200+ grammars25 (ts, tsx, js, jsx, json, css, html, svelte, vue, python, go, sql, shell, yaml, markdown, …)
Streaming cost on an open fenceRe-highlights the whole block per flush; ~1–2 ms per line with the JS engineRe-highlights the whole block per flush; well under a millisecond for typical blocks
MaturityStable0.1.x — the API can still move; pin your version
Unknown languageEscaped <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 createTanstackHighlighter
pnpm add shiki                # for createShikiHighlighter
pnpm add @tanstack/highlight  # for createTanstackHighlighter

Wire 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:

  1. An explicit highlighter prop — only reachable if you wrap HighlightedCode in your own component. Use it as an escape hatch.
  2. A Svelte context set under HIGHLIGHT_CONTEXT_KEY by 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).
  3. 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 to plaintext; 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