Profile
Back to NewsBack
GitHub Trending 15 min
Reader Mode
humanspeak/svelte-markdown: 📝 Markdown and HTML renderer for Svelte 5 — built for streaming AI agent output from Claude Code, ChatGPT, and agentic workflows. XSS-safe defaults, token caching, TypeScript types.

humanspeak/svelte-markdown: 📝 Markdown and HTML renderer for Svelte 5 — built for streaming AI agent output from Claude Code, ChatGPT, and agentic workflows. XSS-safe defaults, token caching, TypeScript types.

18 hours ago

@humanspeak/svelte-markdown

A powerful, customizable markdown renderer for Svelte with TypeScript support. Built as a successor to the original svelte-markdown package by Pablo Berganza, now maintained and enhanced by Humanspeak, Inc.

NPM version</a> Build Status</a> AI tokens used building this repo — TokenMaxing</a> Coverage Status</a> License</a> Downloads</a> CodeQL</a> Install size</a> Code Style: Trunk</a> TypeScript</a> Types</a> Maintenance</a>

Features

  • 🔒 HTMLParser2 parsing with default URL and attribute sanitizers (protocol allowlist, on* handler stripping)
  • 🚀 Full markdown syntax support through Marked
  • 💪 Complete TypeScript support with strict typing
  • 🔄 Svelte 5 runes compatibility
  • ✂️ Inline snippet overrides — customize renderers without separate files
  • 🎨 Customizable component rendering system
  • ♿ Semantic default markup, including image alt text and task-list checkboxes
  • 🎯 GitHub-style slug generation for headers
  • 🧪 Comprehensive test coverage (vitest and playwright)
  • 🧩 First-class marked extensions support via extensions prop (e.g., KaTeX math, alerts)
  • 🎨 Opt-in syntax highlighting with one HighlightedCode renderer and your choice of engine (Shiki or TanStack Highlight) — streaming-compatible, tree-shaken out of the core bundle
  • ⚡ LRU token caching avoids repeated parsing of previously seen content
  • 📡 LLM streaming with incremental parsing and token reuse (about 2–3 ms per frame on the mixed-prose benchmark below)
  • 📬 Late and out-of-order packets: writeChunk({ value, offset }) assembles chunks in any arrival order and keeps rendering while gaps fill
  • 🖼️ Smart image lazy loading with fade-in animation

Upgrading to 2.0

Version 2.0 rebuilds the streaming engine. Component props and the writeChunk() / resetStream() API are unchanged, and most apps only need the version bump. Two behaviors changed:

  • Renderers for tokens inside list items and table cells receive only their own token fields; they no longer inherit the parent list's or table's raw, text, items, header, or rows.
  • The default code renderer emits one text node per line, so code.firstChild is the first line only. textContent and innerHTML are unchanged.
Read the
2.0 upgrade guide for the full list, including streaming output fixes and the new IncrementalParser fields.

Upgrading with an AI assistant? Paste this so it reads the right sources first:

I am upgrading @humanspeak/svelte-markdown from 1.x to 2.0 in a Svelte 5 project.
Before changing anything, read these sources:
  • Upgrade guide: https://markdown.svelte.page/docs/migration/v2.md
  • Documentation index for LLMs: https://markdown.svelte.page/llms.txt
  • Full documentation text: https://markdown.svelte.page/llms-full.txt
  • Streaming behavior: https://markdown.svelte.page/docs/advanced/llm-streaming.md
  • Direct parser use: https://markdown.svelte.page/docs/advanced/headless-parser.md
  • Release notes: https://github.com/humanspeak/svelte-markdown/releases
Then search my codebase for:
  1. Custom renderers or snippets used inside lists and tables that read raw,
text, items, header, or rows from props.
  1. Code that reads firstChild or childNodes of rendered <code> elements or of
the markdown container.
  1. Direct IncrementalParser usage.
  2. Tests that snapshot streamed output mid-stream.
List each place that needs a change, explain why using the guide, and propose the smallest fix.

Installation

Requires Svelte 5 and Node.js 22 or newer for package tooling.

npm i -S @humanspeak/svelte-markdown

Or with your preferred package manager:

pnpm add @humanspeak/svelte-markdown
yarn add @humanspeak/svelte-markdown

Basic Usage

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

const source =

This is a header

This is a paragraph with bold and <em>mixed HTML</em>.

  • List item with \inline code\
  • And a link
* With nested items * Supporting full markdown </script>

<SvelteMarkdown {source} />

Rendering AI Agent Output

Modern AI coding agents — Claude Code, Codex, agentic workflows — increasingly emit HTML alongside markdown for richer output (design mockups, dashboards, reports, interactive artifacts). @humanspeak/svelte-markdown is built for this:

  • Mixed markdown + HTML in a single source — agents can interleave standard markdown with rich HTML (tables, SVG, custom elements) without a second renderer
  • XSS defaults on by default — javascript: URLs and on* handlers stripped from agent output before render, no opt-in required (see Security)
  • Sanitization during streaming — URL and attribute sanitizers also run on progressively rendered content; partial HTML tags are buffered while they are incomplete
  • Custom HTML tag support — route semantic markup like , , or your own design-system tags to your own components via renderers.html (see Custom HTML Tags)
<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import type { StreamingChunk } from '@humanspeak/svelte-markdown'

let markdown: { writeChunk: (chunk: StreamingChunk) => void } | undefined

async function streamFromAgent(response: Response) { if (!response.ok || !response.body) throw new Error('Streaming response unavailable') const reader = response.body.getReader() const decoder = new TextDecoder() while (true) { const { done, value } = await reader.read() if (done) break markdown?.writeChunk(decoder.decode(value, { stream: true })) } markdown?.writeChunk(decoder.decode()) } </script>

<SvelteMarkdown bind:this={markdown} source="" streaming />

For background on why HTML has become a common agent output format, see Thariq's post: Using Claude Code: The Unreasonable Effectiveness of HTML. For the full streaming API (offset chunks, reset, websocket patterns), see LLM Streaming below.

TypeScript Support

The package is written in TypeScript and includes full type definitions:

import type {
    Renderers,
    Token,
    TokensList,
    SvelteMarkdownOptions,
    SvelteMarkdownProps,
    MarkedExtension
} from '@humanspeak/svelte-markdown'

Exports for programmatic overrides

You can import renderer maps and helper keys to selectively override behavior.

import SvelteMarkdown, {
    // Maps
    defaultRenderers, // markdown renderer map
    Html, // HTML renderer map

// Keys rendererKeys, // markdown renderer keys (excludes 'html') htmlRendererKeys, // HTML renderer tag names

// Utility components Unsupported, // markdown-level unsupported fallback UnsupportedHTML // HTML-level unsupported fallback } from '@humanspeak/svelte-markdown'

// Example: override a subset const customRenderers = { ...defaultRenderers, link: CustomLink, html: { ...Html, span: CustomSpan } }

// Optional: iterate keys when building overrides dynamically for (const key of rendererKeys) { // if (key === 'paragraph') customRenderers.paragraph = MyParagraph } for (const tag of htmlRendererKeys) { // if (tag === 'div') customRenderers.html.div = MyDiv }

Notes

  • rendererKeys intentionally excludes html. Use htmlRendererKeys for HTML tag overrides.
  • Unsupported and UnsupportedHTML display suppressed markup as escaped text. They do not remove its content; use a custom renderer if you want to hide it entirely.

Helper utilities for allow/deny strategies

These helpers make it easy to either allow only a subset or exclude only a subset of renderers without writing huge maps by hand.

  • HTML helpers
- buildUnsupportedHTML(): returns a map where every HTML tag uses UnsupportedHTML. - allowHtmlOnly(allowed): enable only the provided tags; others use UnsupportedHTML. - Accepts tag names like 'strong' or tuples like ['div', MyDiv] to plug in custom components. - excludeHtmlOnly(excluded, overrides?): disable only the listed tags (mapped to UnsupportedHTML), with optional overrides for non-excluded tags using tuples.
  • Markdown helpers (non-HTML)
- buildUnsupportedRenderers(): returns a map where all markdown renderers (except html) use Unsupported. - allowRenderersOnly(allowed): enable only the provided markdown renderer keys; others use Unsupported. - Accepts keys like 'paragraph' or tuples like ['paragraph', MyParagraph] to plug in custom components. - excludeRenderersOnly(excluded, overrides?): disable only the listed markdown renderer keys, with optional overrides for non-excluded keys using tuples.

HTML helpers in context

The HTML helpers return an HtmlRenderers map to be used inside the html key of the overall renderers map. They do not replace the entire renderers object by themselves.

Basic: keep markdown defaults, allow only a few HTML tags (others become UnsupportedHTML):

import SvelteMarkdown, { defaultRenderers, allowHtmlOnly } from '@humanspeak/svelte-markdown'

const renderers = { ...defaultRenderers, // keep markdown defaults html: allowHtmlOnly(['strong', 'em', 'a']) // restrict HTML }

Allow a custom component for one tag while allowing others with defaults:

import SvelteMarkdown, { defaultRenderers, allowHtmlOnly } from '@humanspeak/svelte-markdown'

const renderers = { ...defaultRenderers, html: allowHtmlOnly([['div', MyDiv], 'a']) }

Exclude just a few HTML tags; keep all other HTML tags as defaults:

import SvelteMarkdown, { defaultRenderers, excludeHtmlOnly } from '@humanspeak/svelte-markdown'

const renderers = { ...defaultRenderers, html: excludeHtmlOnly(['span', 'iframe']) }

// Or exclude 'span', but override 'a' to CustomA const renderersWithOverride = { ...defaultRenderers, html: excludeHtmlOnly(['span'], [['a', CustomA]]) }

Disable all HTML quickly (markdown defaults unchanged):

import SvelteMarkdown, { defaultRenderers, buildUnsupportedHTML } from '@humanspeak/svelte-markdown'

const renderers = { ...defaultRenderers, html: buildUnsupportedHTML() }

Markdown-only (non-HTML) scenarios

Allow only paragraph and link with defaults, disable others:

import { allowRenderersOnly } from '@humanspeak/svelte-markdown'

const md = allowRenderersOnly(['paragraph', 'link'])

Exclude just link; keep others as defaults:

import { excludeRenderersOnly } from '@humanspeak/svelte-markdown'

const md = excludeRenderersOnly(['link'])

Disable all markdown renderers (except html) quickly:

import { buildUnsupportedRenderers } from '@humanspeak/svelte-markdown'

const md = buildUnsupportedRenderers()

Combine HTML and Markdown helpers

You can combine both maps in renderers for SvelteMarkdown.

<script lang="ts">
    import SvelteMarkdown, { allowRenderersOnly, allowHtmlOnly } from '@humanspeak/svelte-markdown'

const renderers = { // Only allow a minimal markdown set ...allowRenderersOnly(['paragraph', 'link']),

// Configure HTML separately (only strong/em/a) html: allowHtmlOnly(['strong', 'em', 'a']) }

const source = # Title\n\nThis has <strong>HTML</strong> and a link. </script>

<SvelteMarkdown {source} {renderers} />

Custom Renderer Example

Here's a complete example of a custom renderer with TypeScript support:

<script lang="ts">
    import type { Snippet } from 'svelte'

interface Props { children?: Snippet href?: string title?: string }

const { href = '', title = '', children }: Props = $props() </script>

<a {href} {title} class="custom-link"> {@render children?.()} </a>

Save this as CustomLink.svelte, then use it with renderers={{ link: CustomLink }} after importing the component. Other default implementations are in the renderers folder.

Snippet Overrides (Svelte 5)

For simple tweaks — adding a class, changing an attribute, wrapping in a div — you can override renderers inline with Svelte 5 snippets instead of creating separate component files:

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

const source = '# Hello\n\nA paragraph with a link.' </script>

<SvelteMarkdown {source}> {#snippet paragraph({ children })} <p class="prose">{@render children?.()}</p> {/snippet}

{#snippet heading({ depth, id, children })} {#if depth === 1} <h1 {id} class="title">{@render children?.()}</h1> {:else} <svelte:element this={h${depth}} {id}>{@render children?.()}</svelte:element> {/if} {/snippet}

{#snippet link({ href, title, children })} <a {href} {title} target="_blank" rel="noopener noreferrer"> {@render children?.()} </a> {/snippet}

{#snippet code({ lang, text })} <pre class="highlight {lang}"><code>{text}</code></pre> {/snippet} </SvelteMarkdown>

How it works

  • Container renderers (paragraph, heading, blockquote, list, etc.) receive a children snippet for nested content
  • Leaf renderers (code, image, hr, br) receive only data props — no children
  • Precedence: snippet > component renderer > default. If both a snippet and a renderers.paragraph component are provided, the snippet wins

HTML tag snippets

HTML tag snippets use an html_ prefix to avoid collisions with markdown renderer names:

<SvelteMarkdown {source}>
    {#snippet html_div({ attributes, children })}
        <div class="custom-wrapper" {...attributes}>{@render children?.()}</div>
    {/snippet}

{#snippet html_a({ attributes, children })} <a {...attributes} target="_blank" rel="noopener noreferrer"> {@render children?.()} </a> {/snippet} </SvelteMarkdown>

All HTML snippets share the exported HtmlSnippetProps interface: { attributes?: Record, children?: Snippet }.

Custom HTML Tags

You can render arbitrary (non-standard) HTML tags like , , or any custom element by providing a renderer or snippet for the tag name. The parsing pipeline accepts any tag name — you just need to tell SvelteMarkdown how to render it.

Component renderer approach:

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import ClickButton from './ClickButton.svelte'

const source = '<click>Click Me</click>' const renderers = { html: { click: ClickButton } } </script>

<SvelteMarkdown {source} {renderers} />

Snippet override approach:

<SvelteMarkdown source={'<click data-action="submit">Click Me</click>'}>
    {#snippet html_click({ attributes, children })}
        <button {...attributes} class="custom-btn">{@render children?.()}</button>
    {/snippet}
</SvelteMarkdown>

Both approaches work for any tag name. Snippet overrides take precedence over component renderers when both are provided.

Self-closing and empty tags are supported alongside the paired form, and render your component with no children:

<SvelteMarkdown source={'<click />'} renderers={{ html: { click: ClickButton } }} />

Tag names are case-insensitive, as they are in HTML. Tags are normalized to lowercase when parsed, and renderer keys and html_* snippet names are normalized the same way, so , and all reach the same renderer however they are registered — and identically whether the tag stands alone or is nested inside other HTML.

One consequence worth knowing: a tag has exactly one entry, so registering the same tag under two casings is a duplicate rather than two renderers, and the last one wins. An entry you provide always takes precedence over the built-in renderer — including null, which blocks the tag entirely:

<!-- blocks <iframe>, <IFRAME> and <IFrame> alike -->
<SvelteMarkdown {source} renderers={{ html: { IFRAME: null } }} />

The tag passed to custom renderers and to the sanitizeUrl / sanitizeAttributes hooks is always lowercase, so context.tag === 'iframe' is reliable.

Marked Extensions

Use marked extensions via the extensions prop. SvelteMarkdown ships tokenizers and renderers for KaTeX, Mermaid, GitHub-style alerts, and footnotes. Alerts and footnotes need no additional dependencies; math and diagrams require their optional peers. Use the @humanspeak/svelte-markdown/extensions subpath or a dedicated subpath such as extensions/alert or extensions/katex. Dedicated subpaths let you import only the feature you need. Third-party extensions still work too; the component handles registering tokenizers internally and you just provide renderers for the custom token types.

KaTeX Math Rendering

The package includes built-in markedKatex and KatexRenderer helpers. Install katex as an optional peer dependency and load its CSS:

npm install katex

Default delimiter set (mirrors KaTeX's own auto-render defaults):

| Delimiter pair | Level | displayMode | | -------------------------------------------------------------- | ------ | ------------- | | \(...\) | inline | false | | \[...\] (own-line) | block | true | | $$...$$ (own-line) | block | true | | \begin{equation}...\end{equation} and other AMS environments | block | true |

Single-dollar inline ($x^2$) is off by default — KaTeX itself excludes it from auto-render to avoid currency-string clashes like $5,000. Pass { singleDollarInline: true } to enable it; it uses a whitespace-bounded rule so currency strings still won't match.

Component renderer approach:

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import type { RendererComponent, Renderers } from '@humanspeak/svelte-markdown'
    import { markedKatex, KatexRenderer } from '@humanspeak/svelte-markdown/extensions/katex'
    import 'katex/dist/katex.min.css'

interface KatexRenderers extends Renderers { inlineKatex: RendererComponent blockKatex: RendererComponent }

const renderers: Partial<KatexRenderers> = { inlineKatex: KatexRenderer, blockKatex: KatexRenderer } </script>

<SvelteMarkdown source={Euler's identity: \\(e^{i\\pi} + 1 = 0\\)} extensions={[markedKatex()]} {renderers} />

KatexRenderer hardcodes throwOnError: false so a single malformed expression renders as a tinted error span instead of throwing — if you need stricter behavior, supply your own component for the inlineKatex / blockKatex keys.

Snippet override approach (no separate component file needed):

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import { markedKatex } from '@humanspeak/svelte-markdown/extensions/katex'
    import katex from 'katex'
    import 'katex/dist/katex.min.css'
</script>

<SvelteMarkdown source={Euler's identity: \\(e^{i\\pi} + 1 = 0\\)} extensions={[markedKatex()]}> {#snippet inlineKatex(props)} {@html katex.renderToString(props.text, { throwOnError: false, displayMode: false })} {/snippet} {#snippet blockKatex(props)} {@html katex.renderToString(props.text, { throwOnError: false, displayMode: true })} {/snippet} </SvelteMarkdown>

Mermaid Diagrams (Async Rendering)

The package includes built-in markedMermaid and MermaidRenderer helpers for Mermaid diagram support. Install mermaid as an optional peer dependency:

npm install mermaid

Mermaid 12.0.0 pulls in Chevrotain packages that pin lodash-es 4.17.23, which is affected by CVE-2026-4800 and CVE-2026-2950. Until those upstream pins are updated, consumers using this dependency tree should override lodash-es to ^4.18.1 in their application and regenerate their lockfile. For npm, add this to the application's root package.json:

{
    "overrides": {
        "lodash-es": "^4.18.1"
    }
}

For pnpm, add overrides: { 'lodash-es@<4.18.0': '^4.18.1' } to the application's pnpm-workspace.yaml. This repository applies that override for its own tests and docs; library overrides do not propagate to consumer applications.

Then use the built-in helpers — no boilerplate needed:

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import type { RendererComponent, Renderers } from '@humanspeak/svelte-markdown'
    import { markedMermaid, MermaidRenderer } from '@humanspeak/svelte-markdown/extensions'

// markdown containing fenced mermaid code blocks let { source } = $props()

interface MermaidRenderers extends Renderers { mermaid: RendererComponent }

const renderers: Partial<MermaidRenderers> = { mermaid: MermaidRenderer } </script>

<SvelteMarkdown {source} extensions={[markedMermaid()]} {renderers} />

markedMermaid() is a zero-dependency tokenizer that converts `mermaid code blocks into custom tokens. MermaidRenderer lazy-loads mermaid in the browser, renders SVG asynchronously, and automatically re-renders when dark/light mode changes.

You can also use snippet overrides to wrap MermaidRenderer with custom markup:

<SvelteMarkdown source={markdown} extensions={[markedMermaid()]}>
    {#snippet mermaid(props)}
        <div class="my-diagram-wrapper">
            <MermaidRenderer text={props.text} />
        </div>
    {/snippet}
</SvelteMarkdown>

The Mermaid tokenizer is synchronous, so parsing and streaming remain enabled. Diagram rendering happens in the browser after mount; server-rendered pages show a loading placeholder for diagrams. The snippet delegates that async rendering to MermaidRenderer and controls only its layout.

GitHub Alerts

Built-in support for GitHub-style alerts/admonitions. Five alert types are supported: NOTE, TIP, IMPORTANT, WARNING, and CAUTION.

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import type { RendererComponent, Renderers } from '@humanspeak/svelte-markdown'
    import { markedAlert, AlertRenderer } from '@humanspeak/svelte-markdown/extensions'

const source = > [!NOTE] > Useful information that users should know.

> [!WARNING] > Urgent info that needs immediate attention.

interface AlertRenderers extends Renderers { alert: RendererComponent }

const renderers: Partial<AlertRenderers> = { alert: AlertRenderer } </script>

<SvelteMarkdown {source} extensions={[markedAlert()]} {renderers} />

AlertRenderer renders a

with a title — no inline styles, so you can theme it with your own CSS. You can also use snippet overrides:

<SvelteMarkdown source={markdown} extensions={[markedAlert()]}>
    {#snippet alert(props)}
        <div class="my-alert my-alert-{props.alertType}">
            <strong>{props.alertType}</strong>
            <p>{props.text}</p>
        </div>
    {/snippet}
</SvelteMarkdown>

Footnotes

Built-in support for footnote references and definitions. Footnote references ([^id]) render as superscript links, and definitions ([^id]: content) render as a numbered list at the end of the document with back-links.

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import type { RendererComponent, Renderers } from '@humanspeak/svelte-markdown'
    import {
        markedFootnote,
        FootnoteRef,
        FootnoteSection
    } from '@humanspeak/svelte-markdown/extensions'

const source = Here is a statement[^1] with a footnote.

Another claim[^note] that needs a source.

[^1]: This is the first footnote. [^note]: This is a named footnote.

interface FootnoteRenderers extends Renderers { footnoteRef: RendererComponent footnoteSection: RendererComponent }

const renderers: Partial<FootnoteRenderers> = { footnoteRef: FootnoteRef, footnoteSection: FootnoteSection } </script>

<SvelteMarkdown {source} extensions={[markedFootnote()]} {renderers} />

Definitions may start with zero to three spaces. Continuation text must be indented by at least four spaces or one tab; one indentation unit is removed from the rendered plain text. A blank line belongs to a definition only when an indented continuation follows it, so an unindented paragraph, heading, list, fence, or HTML block after a definition remains normal document content. Empty definition bodies are supported. If a label is defined more than once, the first definition in document order wins.

FootnoteRef renders {id} and FootnoteSection renders an

    with bidirectional links. Simple labels containing only ASCII letters, digits, _, and - retain the legacy IDs (fn-my-note and fnref-my-note). Other UTF-16 code units use a deterministic ~ plus four-digit lowercase hexadecimal encoding: for example, [^x:2] uses fn-x~003a2. Repeated references retain their visible label while receiving occurrence IDs such as fnref-note, fnref-note:ref:2, and fnref-note:ref:3; the definition renders one backlink for each occurrence. Link fragments URI-encode these full DOM IDs.

    Custom component renderers and snippet overrides receive additive navigation props. A footnoteRef receives { id, referenceId? }, where referenceId is the prepared occurrence DOM ID. A footnoteSection receives { footnotes }, with each record shaped as { id, text, backrefs?: string[] }; backrefs contains the prepared reference DOM IDs. The built-in renderers keep their legacy first-reference fallback when these optional props are omitted.

    IDs are coordinated within one SvelteMarkdown document. Separate component instances using the same labels are not automatically namespaced, so applications that place multiple rendered documents in one page should provide custom renderers if cross-document ID uniqueness is required. Footnote bodies are rendered as escaped plain text rather than Markdown, and this extension does not claim full CommonMark or GFM footnote compatibility.

    Syntax Highlighting (Shiki or TanStack Highlight)

    Unlike the marked extensions above, syntax highlighting is a renderer-level override: you replace the default code renderer with HighlightedCode, so there is no extensions prop entry and no marked tokenizer involved. HighlightedCode is engine-agnostic — it talks to a two-method CodeHighlighter interface, and two engines ship as opt-in factories on their own subpaths. Both are synchronous, so the code renderer never trips the async-extension guard — streaming stays fully enabled.

    | Subpath | Exports | Optional peer | Core + ts/js/json | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------- | ----------------------- | | @humanspeak/svelte-markdown/extensions/highlight | HighlightedCode, CodeHighlighter, HIGHLIGHT_CONTEXT_KEY, setCodeHighlighter | none | — | | @humanspeak/svelte-markdown/extensions/shiki | createShikiHighlighter — TextMate grammars, inline theme colors | shiki | ~87 KB gzip | | @humanspeak/svelte-markdown/extensions/tanstack-highlight | createTanstackHighlighter — hand-written scanners, semantic th-* classes, CSS themes | @tanstack/highlight | ~4 KB gzip |

    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.

    Pick Shiki for editor-exact colors and 200+ grammars; pick TanStack Highlight for chat and agent UIs that stream a lot of code and care about weight (25 languages, themes are CSS variables so light/dark is a CSS toggle). Install the peer you use:

    npm install shiki                # or
    npm install @tanstack/highlight

    Import only the languages (and, for Shiki, themes) you need, build a highlighter, register it, then map HighlightedCode to the code renderer:

    <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 { createThemeCss } from '@tanstack/highlight/theme'
        import githubDark from '@tanstack/highlight/themes/github-dark'
        import githubLight from '@tanstack/highlight/themes/github-light'

    // Register once (module singleton). Every HighlightedCode instance resolves it. setCodeHighlighter(createTanstackHighlighter({ languages: [ts] })) // TanStack emits classes only — ship a theme stylesheet (match darkSelector to your app). const themeCss = createThemeCss({ light: githubLight, dark: githubDark, darkSelector: 'html.dark' })

    const source = '

    ts\nconst answer: number = 42\n`'

    {@html }

    The Shiki variant is the same shape: createShikiHighlighter({ langs: [ts], themes: [githubDark] }) from extensions/shiki with shiki/langs/ and shiki/themes/ imports, and no stylesheet since colors are inlined.

    The highlighter is resolved in priority order: an explicit highlighter prop → a Svelte context set under HIGHLIGHT_CONTEXT_KEY (for per-subtree engines/themes or SSR request isolation) → the module singleton from setCodeHighlighter. Unregistered languages and any per-block failure degrade to an escaped fallback <pre> rather than throwing mid-stream (Shiki: shiki-fallback; TanStack: its own th-code--plaintext so the block keeps your theme, or th-code--fallback with plaintextFallback: false). Both engines escape the code they emit and every fallback escapes its inputs, so the {@html} sink only ever receives library-generated or explicitly-escaped markup (the same trust model as KatexRenderer / MermaidRenderer). You can also implement CodeHighlighter yourself to wrap any other highlighter.

    Backward compatibility. extensions/shiki still exports ShikiCode, SHIKI_CONTEXT_KEY, setShikiHighlighter, getShikiHighlighter, and ShikiHighlighter; they are aliases of the extensions/highlight names (same component, same symbol, same singleton). The only visible change is that with no highlighter configured at all the renderer's fallback <pre> carries highlight-fallback instead of shiki-fallback.

    Bundle guidance — this is opt-in for a reason. The cost lands only when you construct a highlighter: importing HighlightedCode alone pulls in nothing from either engine, the core SvelteMarkdown bundle stays engine-free, and the two engines never leak into each other (all enforced by scripts/tree-shaking.mjs). Import narrowly: every extra grammar, language, or theme you import is bundled. Shiki's JS engine keeps SSR trivial (no WASM); highlight-heavy client apps can opt into its faster oniguruma-WASM engine, which is still streaming-safe. See the side-by-side streaming demo for live per-engine timings.

    How It Works

    Marked extensions define custom token types with a name property (e.g., inlineKatex, blockKatex, alert). When you pass extensions via the extensions prop, SvelteMarkdown automatically extracts these token type names and makes them available as both component renderer keys and snippet override names.

    To find the token type names for any extension, check its source or documentation for the name field in its extensions array:

    js // Example: markedKatex (built-in) registers tokens named "inlineKatex" and "blockKatex" // → use renderers={{ inlineKatex: ..., blockKatex: ... }} // → or {#snippet inlineKatex(props)} and {#snippet blockKatex(props)}

    // Example: a custom alert extension registers a token named "alert" // → use renderers={{ alert: AlertComponent }} // → or {#snippet alert(props)}

    Each snippet/component receives the token's properties as props (e.g., text, displayMode for KaTeX; text, alertType for alerts). Marked HTML renderer functions do not replace Svelte renderers; provide a component or snippet for each custom token type.

    Dynamic Extension Objects

    SvelteMarkdown includes extension identity in its internal parser cache. If you replace an extension object, tokenizer object, or tokenizer function, the parser treats that as a new parsing configuration and re-parses the source.

    This means extension factories can safely close over reactive state:

    svelte

    {#snippet displayValue(props)} {props.text} {/snippet}

    When displayFormat changes from decimal to percent, the new extension object invalidates the cached parse even though the markdown source is unchanged. The updated token props flow into your renderer or snippet without requiring a manual cache key.

    See the full documentation and interactive demo.

    TypeScript

    All snippet prop types are exported for use in external components:

    typescript import type { ParagraphSnippetProps, HeadingSnippetProps, LinkSnippetProps, CodeSnippetProps, HtmlSnippetProps, SnippetOverrides, HtmlSnippetOverrides } from '@humanspeak/svelte-markdown'
    ## Advanced Features

    Table Support with Mixed Content

    The package excels at handling complex nested structures and mixed content:

    markdown | Type | Content | | ---------- | --------------------------------------- | | Nested |
    bold and _italic_
    | | Mixed List |
    • Item 1
    • Item 2
    | | Code | inline code |
    ### HTML in Markdown

    Seamlessly mix HTML and Markdown:

    markdown
    ### This is a Markdown heading inside HTML And here's some bold text too!

    Click to expand

    • This is a markdown list
    • Inside an HTML details element
    • Supporting bold and _italic_ text
    ### Markdown preprocessor (alpha)
    

    We’re developing a Markdown preprocessor for authored pages and components. It parses Markdown at build time while preserving SvelteMarkdown’s custom Markdown renderers, and compiles embedded Svelte components and expressions without requiring special delimiters. Static pages can use the same renderer customization as runtime Markdown, without parsing the document again in the browser.

    Status: alpha. The API and supported syntax are still evolving. This alpha does not yet provide a published preprocessor entry point.

    Performance

    Intelligent Token Caching

    Parsed tokens are automatically cached using an LRU strategy, avoiding repeated lexing for previously seen content. The benefit depends on document size and parsing configuration. The cache uses FNV-1a hashing keyed on source + options, with LRU eviction (default 50 documents) and TTL expiration (default 5 minutes). No configuration required.

    typescript import { tokenCache, TokenCache } from '@humanspeak/svelte-markdown'

    // Manual cache management tokenCache.clearAllTokens() tokenCache.deleteTokens(markdown, options)

    // Custom cache instance const myCache = new TokenCache({ maxSize: 100, ttl: 10 60 1000 })

    tokenCache is the shared cache used by the component. Creating a separate
    TokenCache does not replace it; a custom instance is for your own token caching.
    Treat cache option objects as immutable and create a new object when options change.

    > Cache entries store the source string alongside its > tokens so a hit is verified against hash collisions — getTokens, > setTokens, and hasTokens are the supported token API. The raw > get()/set() methods inherited from MemoryCache now operate on the > wrapped { source, tokens } entry shape, not bare token arrays.

    Smart Image Lazy Loading

    The default Markdown image renderer lazy loads using native loading="lazy" and IntersectionObserver prefetching, with a smooth fade-in animation and error state handling. When an image URL changes, its load/error state resets for the new request; unchanged image URLs keep their existing DOM and completed state during updates. Reusing the same failed URL does not automatically retry it. Raw HTML <img> tags use the HTML renderer and do not inherit this behavior. To disable lazy loading or provide custom retry behavior for Markdown images, provide a custom image renderer:

    svelte

    {text}
    svelte

    ### Optional streaming text segments and Motion

    Default rendering remains ordinary unwrapped text. streamingText (default false) enables immutable source arrival metadata only with synchronous streaming; animation requires an explicitly selected renderer or snippet.

    svelte

    Install @humanspeak/svelte-motion@^2.0.1-0 explicitly for the optional streaming/motion subpath. It exports FadeWords (opacity), RiseWords (opacity plus vertical rise), FadeCharacters (graphemes), Fade (a whole extension token such as math), StreamingMotionProps and StreamingFadeProps. Motion is an optional peer; installing it alone enables nothing. Presets accept enabled, ink, animateRevisions, animateInitialContent, initial, animate, transition, variants, custom, locale, segmenter and a complete markup replacement segment snippet. Whitespace stays literal; RiseWords motion spans and ink-wipe wrappers are inline-block. Consumers control reduced motion via enabled.

    All three presets use a leaf-local batch stagger of 0.02s capped at 0.16s. FadeWords fades over 0.65s (linear). RiseWords adds an 8px rise over 0.4s (easeOut) to the same fade. FadeCharacters keeps a 0.18s fade. FadeWords and RiseWords also apply an ink wipe, a feathered left-to-right mask reveal over 0.8s on arriving words; pass ink={false} to disable it or ink={{ duration }} to retime it (FadeCharacters leaves it off unless ink is passed). Consumer initial and animate replace the preset targets; transition replaces the entire default transition, including easing and stagger.

    The core exports headless StreamingText with text, optional metadata, granularity: 'word' | 'grapheme' (word by default), locale, segmenter, and segment: Snippet<[StreamingTextSegment]>. Forward leaf/snippet streamingText into metadata. Without a snippet it emits escaped text without wrappers or segmentation. No Motion installation is required for core or headless use.

    StreamingTextSegment has readonly id, text, index, start, end, isNew, batchId, batchIndex, isWhitespace and change: 'baseline' | 'append' | 'revision'. Offsets are leaf-local UTF-16. Creation eligibility and batch fields persist while an unfinished word grows. StreamingTextMetadata has readonly epoch, leafId, renderBatchId, provenance: 'exact' | 'unknown', readonly ranges and, on extension tokens only, arrival (StreamingTextArrival: change, batchId, revealedBeforeBatch). Each StreamingTextRange has readonly start, end, originId, change, batchId and revealedBeforeBatch. Other exported types are StreamingTextArrival, StreamingTextProps, StreamingTextSpan, StreamingTextSegmenter, StreamingTextGranularity and StreamingTextChange.

    First/reset content is baseline; appends are arrivals; offset overwrites are revisions. Structural remounts do not replay already revealed source characters. Tokens produced by custom extension tokenizers (for example KaTeX math) have unknown provenance and no word segments; surrounding markdown text keeps its entrances. Wrap their renderer in Fade (<Fade {streamingText}><KatexRenderer {text} /></Fade>, block for display math) to fade the whole token in once, when it arrives; remounts of already revealed tokens and baseline content render without an entrance. Fade accepts streamingText, enabled, block, animateRevisions, animateInitialContent, initial, animate and transition. A walkTokens hook or custom tokenizer makes the whole parse unknown and suppresses automatic entrances. Tracking is per instance and discarded on resets, replacements, mode changes and toggles. Unicode boundaries require Intl.Segmenter or validated custom spans covering the exact text; locale/segmentation changes rebaseline. Changed leaves are resegmented with full context; completed unchanged leaves stay cached. Segment DOM cost is opt-in, and long open blocks/full-parser fallbacks retain their existing costs. Code renderers are excluded.

    See the streaming text API and complete prop/imperative, custom snippet, SSR and reduced-motion examples and executable demo with actual source.

    LLM Streaming

    For real-time rendering of AI responses, enable the streaming prop. Append-only updates normally re-parse the open block at the end of the source and reuse unchanged tokens. Edits, reference definitions, and some extensions can require a full-document parse; work is not constant for every document or configuration.

    The preferred API is now imperative: bind the component instance and call writeChunk() as chunks arrive. This avoids prop reactivity edge cases like identical consecutive string chunks being coalesced.

    svelte

    For websocket-style offset patches, pass an object chunk instead:
    ts markdown?.writeChunk({ value: 'world', offset: 6 })
    Object chunks overwrite the internal buffer at offset. This is overwrite semantics, not insert semantics: the chunk replaces characters starting at that index and preserves any trailing content after the overwritten span.

    Offsets count JavaScript string positions (UTF-16 code units), not bytes. If offset skips ahead, missing positions are padded with spaces. A chunk that opens a gap larger than 1,000,000 positions is dropped with a warning. There is no delete or truncate behavior in offset mode.

    Typical websocket-style usage can arrive out of order:

    ts markdown?.writeChunk({ value: ' world', offset: 5 }) markdown?.writeChunk({ value: 'Hello', offset: 0 })
    The internal buffer converges as later patches fill earlier gaps.

    You can reset the internal streaming buffer at any time:

    ts markdown?.resetStream('') markdown?.resetStream('# Seeded response')
    The first successful write after a reset locks the stream into one input mode:

    • string chunks: append mode
    • { value, offset } chunks: offset mode
    Switching modes before resetStream() or a source prop reset logs a warning and drops the chunk. Offset chunks must use a non-negative safe integer offset.

    Setting the source prop to a new value also resets the imperative buffer, seeds a new baseline value, and unlocks the input mode. Re-assigning the same value is not a change and resets nothing — see the warning below.

    Resetting between messages

    The streaming buffer, the incremental parser, and the input-mode lock are all per-component-instance state. They outlive any single message. If a component instance is reused for a second stream without being reset, the new stream starts on top of the previous message's buffer.

    This bites the common chat-transcript pattern, because Svelte reuses the component instance whenever it isn't keyed by message identity:

    svelte {#each messages as message} {/each} ``

    Holding source="" for the entire conversation means the source` prop never changes, so nothing ever triggers the implicit rese

    ... (README truncated for length)

Chat with me