svelte-markdown

作者 humanspeak已验证

📝 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.

130
Stars
6
Forks
TypeScript
语言
2026/8/23
添加时间

⚠️ 第三方软件声明

本 Skill 为第三方开源软件,独立托管于 GitHub。SkillTip 仅为信息目录,不控制或维护底层仓库。所显示的安全检查为自动化且范围有限,安装前请自行审查源码。

阅读服务条款

安装

添加到你的 Claude Code skills 目录:

# Add to your Claude Code skills
git clone https://github.com/humanspeak/svelte-markdown

快速入门

使用 svelte-markdown 等 Skills 的指南。

安全报告

已验证

上次扫描:—

{
  "status": "PASSED",
  "issues": []
}

README.md

@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 Build Status AI tokens used building this repo — TokenMaxing Coverage Status License Downloads CodeQL Install size Code Style: Trunk TypeScript Types Maintenance

Features

  • 🔒 Secure HTML parsing via HTMLParser2 with built-in XSS defaults (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
  • ♿ WCAG 2.1 accessibility compliance
  • 🎯 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 Shiki syntax highlighting — streaming-compatible, tree-shaken out of the core bundle
  • ⚡ Intelligent token caching (50-200x faster re-renders)
  • 📡 LLM streaming mode with incremental rendering (~1.6ms avg per update)
  • 🖼️ Smart image lazy loading with fade-in animation

Installation

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](https://svelte.dev)
  * 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 defaultjavascript: URLs and on* handlers stripped from agent output before render, no opt-in required (see Security)
  • Streaming-aware sanitization — when streaming is enabled, each token is sanitized as it's emitted; mid-tag partials buffer until well-formed, so progressive HTML from an LLM renders without flicker
  • Custom HTML tag support — route semantic markup like <tool-call>, <thinking>, 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) {
        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 }))
        }
    }
</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,
    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 are available if you want a pass-through fallback strategy.

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](https://example.com).`
</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>

If you would like to extend other renderers please take a look inside the renderers folder for the default implentation of them. If you would like feature additions please feel free to open an issue!

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](https://example.com).'
</script>

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

    {#snippet heading({ depth, children })}
        {#if depth === 1}
            <h1 class="title">{@render children?.()}</h1>
        {:else}
            <h2>{@render children?.()}</h2>
        {/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 a uniform props interface: { attributes?: Record<string, any>, children?: Snippet }.

Custom HTML Tags

You can render arbitrary (non-standard) HTML tags like <click>, <tooltip>, 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.

Marked Extensions

Use marked extensions via the extensions prop. SvelteMarkdown ships first-class extensions for KaTeX, Mermaid, GitHub-style alerts, and footnotes from the @humanspeak/svelte-markdown/extensions subpath — no third-party packages required. 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 pairLeveldisplayMode
\(...\)inlinefalse
\[...\] (own-line)blocktrue
$$...$$ (own-line)blocktrue
\begin{equation}...\end{equation} and other AMS environmentsblocktrue

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'

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

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

<svelte:head>
    <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css"
        crossorigin="anonymous"
    />
</svelte:head>

<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'
    import katex from 'katex'
</script>

<svelte:head>
    <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css"
        crossorigin="anonymous"
    />
</svelte:head>

<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

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>

Since Mermaid rendering is async, the snippet delegates to MermaidRenderer rather than calling mermaid.render() directly. This pattern works for any async extension — keep the async logic in a component and use the snippet for layout customization.

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 <div class="markdown-alert markdown-alert-{type}"> 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} />

FootnoteRef renders <sup><a href="#fn-{id}">{id}</a></sup> and FootnoteSection renders an <ol> with bidirectional links (ref to definition and back). You can also use snippet overrides for custom rendering.

Syntax Highlighting (Shiki)

Unlike the marked extensions above, syntax highlighting is a renderer-level override: you replace the default code renderer with ShikiCode, so there is no extensions prop entry and no marked tokenizer involved. Because highlighting stays synchronous (Shiki's createHighlighterCoreSync + the pure-JS regex engine), the code renderer never trips the async-extension guard — streaming stays fully enabled.

shiki is an optional peer dependency — install it yourself:

npm install shiki

Import only the languages and themes you need (each is a separate ESM module), build a highlighter, register it, then map ShikiCode to the code renderer:

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import {
        createShikiHighlighter,
        ShikiCode,
        setShikiHighlighter
    } 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'

    // Register once (module singleton). Every ShikiCode instance resolves it.
    setShikiHighlighter(createShikiHighlighter({ langs: [js, ts], themes: [githubDark] }))

    const source = '```ts\nconst answer: number = 42\n```'
</script>

<SvelteMarkdown {source} renderers={{ code: ShikiCode }} />

The highlighter is resolved in priority order: an explicit highlighter prop → a Svelte context set under SHIKI_CONTEXT_KEY (for per-subtree themes / SSR request isolation) → the module singleton from setShikiHighlighter. Unregistered or unknown languages, and any per-block failure, degrade to an escaped <pre class="shiki-fallback"> rather than throwing mid-stream. Shiki escapes the code it emits and the fallback escapes its inputs, so the {@html} sink only ever receives library-generated or explicitly-escaped markup (the same trust model as KatexRenderer / MermaidRenderer).

Bundle guidance — this is opt-in for a reason. A highlighter with the pure-JS engine plus two languages (js, ts) and one theme (github-dark) adds roughly 85 KB gzip (~516 KB minified), dominated by the TextMate grammars and the regex engine. That cost lands only when you import and construct a highlighter — the core SvelteMarkdown bundle stays completely shiki-free (enforced by scripts/tree-shaking.mjs), and importing ShikiCode alone (without building a highlighter) pulls in nothing from Shiki. Import narrowly: every extra shiki/langs/* and shiki/themes/* you add is bundled. The JS engine keeps SSR trivial (no WASM); highlight-heavy client apps can opt into Shiki's faster oniguruma-WASM engine, which is still streaming-safe.

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:

// 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, level for alerts).

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:

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import type { MarkedExtension } from 'marked'

    let displayFormat = $state<'decimal' | 'percent'>('decimal')

    const makeDisplayExtension = (format: 'decimal' | 'percent'): MarkedExtension => ({
        extensions: [
            {
                name: 'displayValue',
                level: 'inline',
                tokenizer(src) {
                    const match = /^\((\d+)\)/.exec(src)
                    if (!match) return

                    return {
                        type: 'displayValue',
                        raw: match[0],
                        text: match[1],
                        displayFormat: format
                    }
                }
            }
        ]
    })

    const extensions = $derived([makeDisplayExtension(displayFormat)])
</script>

<SvelteMarkdown source="(42)" {extensions}>
    {#snippet displayValue(props)}
        <span data-format={props.displayFormat}>{props.text}</span>
    {/snippet}
</SvelteMarkdown>

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:

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:

| Type       | Content                                 |
| ---------- | --------------------------------------- |
| Nested     | <div>**bold** and _italic_</div>        |
| Mixed List | <ul><li>Item 1</li><li>Item 2</li></ul> |
| Code       | <code>`inline code`</code>              |

HTML in Markdown

Seamlessly mix HTML and Markdown:

<div style="color: blue">
  ### This is a Markdown heading inside HTML
  And here's some **bold** text too!
</div>

<details>
<summary>Click to expand</summary>

- This is a markdown list
- Inside an HTML details element
- Supporting **bold** and _italic_ text

</details>

Performance

Intelligent Token Caching

Parsed tokens are automatically cached using an LRU strategy, providing 50-200x faster re-renders for previously seen content (< 1ms vs 50-200ms). 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.

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 })

Note (v1.7.12+): 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

Images automatically lazy load using native loading="lazy" and IntersectionObserver prefetching, with a smooth fade-in animation and error state handling. To disable lazy loading, provide a custom Image renderer:

<!-- EagerImage.svelte -->
<script lang="ts">
    let { href = '', title = undefined, text = '' } = $props()
</script>

<img src={href} {title} alt={text} loading="eager" />
<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import EagerImage from './EagerImage.svelte'

    const renderers = { image: EagerImage }
</script>

<SvelteMarkdown source={markdown} {renderers} />

LLM Streaming

For real-time rendering of AI responses from ChatGPT, Claude, Gemini, and other LLMs, enable the streaming prop. This uses a smart diff algorithm that re-parses the full source for correctness but only updates changed DOM nodes, keeping render times constant regardless of document size.

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.

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

    let markdown:
        | {
              writeChunk: (chunk: StreamingChunk) => void
              resetStream: (nextSource?: string) => void
          }
        | undefined

    async function streamResponse() {
        const response = await fetch('/api/chat', { method: 'POST', body: '...' })
        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 }))
        }
    }
</script>

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

For websocket-style offset patches, pass an object chunk instead:

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.

If offset skips ahead, missing positions are padded with spaces. There is no delete or truncate behavior in offset mode.

Typical websocket-style usage can arrive out of order:

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:

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:

<!-- ⚠️ Broken: one recycled instance, no reset between messages -->
{#each messages as message}
    <SvelteMarkdown bind:this={markdown} source="" streaming={true} />
{/each}

Holding source="" for the entire conversation means the source prop never changes, so nothing ever triggers the implicit reset. Concretely:

  • append mode — the next message renders as previous message + new message.
  • offset mode — writes overwrite in place without truncating, so the previous message's tail survives past the end of the new one. This does not self-correct until the new message grows longer than the old one.
  • either mode — the input-mode lock from the previous stream is still in force, so the first chunk of the new stream is dropped with a warning if it uses the other chunk type.

Pass a streamId that changes per message. Whenever its value changes, the component drops the buffer, any pending unflushed chunk, the parser, and the mode lock, then rebaselines on the current source:

<!-- ✅ Correct: streamId identifies the stream -->
{#each messages as message}
    <SvelteMarkdown bind:this={markdown} source="" streaming={true} streamId={message.id} />
{/each}

streamId accepts a string or number and is ignored when streaming is false. Three equivalent ways to get a clean stream, in rough order of preference:

  1. streamId={message.id} — declarative; works even when the instance is recycled.
  2. {#each messages as message (message.id)} — a keyed each gives each message its own instance, so there is nothing to reset. Use this when the key genuinely identifies the message rather than a slot in a virtual list.
  3. markdown.resetStream() — imperative; call it before the first writeChunk() of the new stream.

Note: if you reset by changing source and call writeChunk() in the same tick, the write lands before the prop-driven reset — writeChunk() is synchronous while the reset runs in an effect. Prefer streamId or resetStream(), which take effect immediately.

Appending directly to source is still supported:

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

    let source = $state('')

    function onChunk(chunk: string) {
        source += chunk
    }
</script>

<SvelteMarkdown {source} streaming={true} />

Performance (measured at 100 characters/sec, character mode):

MetricStandard ModeStreaming Mode
Average render~3.6ms~1.6ms
Peak render~21ms~10ms
Dropped frames00

When streaming is false (default), existing behavior is unchanged. The streaming prop skips cache lookups (always a miss during streaming) and uses in-place token array mutation so Svelte only re-renders components for tokens that actually changed.

Default heading ids are precomputed per render pass during streaming, so duplicate-heading suffixes and headerPrefix stay stable across reparses. Custom heading renderers should use the provided id prop for this behavior; calling the slug prop directly advances renderer-local slug state.

Note: streaming is automatically disabled when async extensions (e.g., markedMermaid) are used. A console warning is logged in this case.

See the full streaming documentation and interactive demo.

Available Renderers

  • text - Text within other elements
  • paragraph - Paragraph (<p>)
  • em - Emphasis (<em>)
  • strong - Strong/bold (<strong>)
  • hr - Horizontal rule (<hr>)
  • blockquote - Block quote (<blockquote>)
  • del - Deleted/strike-through (<del>)
  • link - Link (<a>)
  • image - Image (<img>)
  • table - Table (<table>)
  • tablehead - Table head (<thead>)
  • tablebody - Table body (<tbody>)
  • tablerow - Table row (<tr>)
  • tablecell - Table cell (<td>/<th>)
  • list - List (<ul>/<ol>)
  • listitem - List item (<li>)
  • heading - Heading (<h1>-<h6>)
  • codespan - Inline code (<code>)
  • code - Block of code (<pre><code>)
  • html - HTML node
  • rawtext - All other text that is going to be included in an object above

Optional List Renderers

For fine-grained styling:

  • orderedlistitem - Items in ordered lists
  • unorderedlistitem - Items in unordered lists

HTML Renderers

The html renderer is special and can be configured separately to handle HTML elements:

ElementDescription
divDivision element
spanInline container
tableHTML table structure
theadTable header group
tbodyTable body group
trTable row
tdTable data cell
thTable header cell
ulUnordered list
olOrdered list
liList item
codeCode block
emEmphasized text
strongStrong text
aAnchor/link
imgImage

You can customize HTML rendering by providing your own components:

import type { HtmlRenderers } from '@humanspeak/svelte-markdown'

const customHtmlRenderers: Partial<HtmlRenderers> = {
    div: YourCustomDivComponent,
    span: YourCustomSpanComponent
}

Events

The component emits a parsed event when tokens are calculated:

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

    const handleParsed = (tokens: Token[] | TokensList) => {
        console.log('Parsed tokens:', tokens)
    }
</script>

<SvelteMarkdown {source} parsed={handleParsed} />

Props

PropTypeDescription
sourcestring | Token[]Markdown content or pre-parsed tokens
streamingbooleanEnable incremental rendering for LLM streaming
streamIdstring | numberIdentity of the current stream. Changing it resets the streaming buffer, parser, and input-mode lock
renderersPartial<Renderers>Custom component overrides
optionsSvelteMarkdownOptionsMarked parser configuration
isInlinebooleanToggle inline parsing mode
extensionsMarkedExtension[]Third-party marked extensions (e.g., KaTeX math)
sanitizeUrlSanitizeUrlFnURL sanitizer applied before render. Defaults to defaultSanitizeUrl (http/https/mailto/tel/relative)
sanitizeAttributesSanitizeAttributesFnAttribute sanitizer applied before render. Defaults to defaultSanitizeAttributes

Security

This package takes a defense-in-depth approach to security. The defaults below are applied automatically in the Parser before tokens reach any renderer or snippet, so custom renderers cannot bypass them.

On by default:

  • Secure HTML parsing — All HTML is parsed through HTMLParser2's streaming parser rather than innerHTML, preventing script injection
  • URL protocol allowlist (defaultSanitizeUrl) — Markdown link/image URLs and the HTML attributes href, src, action, formaction, cite, data, and poster are restricted to http:, https:, mailto:, tel:, and relative URLs. javascript:, vbscript:, data:, and blob: URIs are blocked (including mixed-case and leading-whitespace variants).
  • Event handler stripping (defaultSanitizeAttributes) — All on* attributes (e.g. onclick, onerror, onload) are removed. The srcdoc attribute is also stripped to prevent iframe HTML injection.
  • No <script> or <style> renderers — Both tags fall through to UnsupportedHTML, which renders them as visible escaped text (e.g. <script>...</script>) rather than executing or applying them.

Configurable controls:

  • Custom sanitizers — Pass sanitizeUrl / sanitizeAttributes props to tighten or loosen the defaults. Use the exported unsanitizedUrl / unsanitizedAttributes passthroughs to disable sanitization entirely (only for trusted input).
  • Granular HTML control — Use allowHtmlOnly() / excludeHtmlOnly() to restrict which HTML tags are rendered (see Helper utilities). For example, excludeHtmlOnly(['iframe', 'form', 'embed']) if you don't want those.
  • Full HTML lockdown — Call buildUnsupportedHTML() to block all raw HTML rendering.
  • Markdown renderer control — Use allowRenderersOnly() / excludeRenderersOnly() to limit which markdown token types are rendered.

Known gaps (not handled by defaults):

  • Inline style="..." attributes are not sanitized. They pass through unchanged (only on* and srcdoc are stripped from attribute maps). Modern browsers don't execute JavaScript via CSS, but visual hijacking (e.g. display:none) and exfiltration via background-image URLs are possible.
  • iframe, form, embed are rendered by default. With on*/srcdoc stripped and src/action protocol-restricted, the worst exploits are blocked, but an iframe to an arbitrary http(s) URL is still possible. Use excludeHtmlOnly(['iframe', 'form', 'embed']) to remove them.
  • srcset and other less common URL attributes are not sanitized. Only the attributes listed above pass through sanitizeUrl. Provide a custom sanitizeAttributes if you need broader coverage.
  • No built-in DOM sanitizer — By design, the package does not bundle DOMPurify or similar. For untrusted input, layer a full sanitizer on top of the defaults above.

Svelte 5 ecosystem

Part of the Humanspeak family of runes-native Svelte 5 packages:

PackageDescription
@humanspeak/svelte-markdownthis packageRuntime markdown renderer for Svelte
@humanspeak/svelte-virtual-listVirtual scrolling for Svelte
@humanspeak/svelte-motionFramer Motion for Svelte 5
@humanspeak/svelte-headless-tableHeadless data tables for Svelte
@humanspeak/svelte-diffDiff comparison for Svelte
@humanspeak/svelte-purifyHTML sanitisation for Svelte
@humanspeak/svelte-virtual-chatVirtual chat viewport for Svelte 5
@humanspeak/memory-cacheIn-memory cache for TypeScript
@humanspeak/svelte-json-view-liteJSON tree viewer for Svelte 5
@humanspeak/svelte-scoped-propsScoped class props for Svelte

License

MIT © Humanspeak, Inc.

Credits

Made with ❤️ by Humanspeak

常见问题

What is svelte-markdown?

svelte-markdown is an open-source ai agents skill for AI coding assistants such as Claude Code, Codex CLI, and ChatGPT, built by humanspeak. 📝 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. It has 130 GitHub stars.

Is svelte-markdown safe to use?

Yes. svelte-markdown passed SkillsLLM's automated security scan — a dependency vulnerability audit plus prompt-injection heuristics — with no high-severity issues. You can read the full report in the Security Report section on this page.

How do I install svelte-markdown?

Clone the repository with "git clone https://github.com/humanspeak/svelte-markdown" and add it to your Claude Code skills directory (see the Installation section above).

What programming language is svelte-markdown written in?

svelte-markdown is primarily written in TypeScript. It is open-source under humanspeak on GitHub, so you can review or fork the full source.

Are there alternatives to svelte-markdown?

Yes. SkillsLLM lists many other AI Agents skills you can browse and compare side by side. Open the AI Agents category from the badge at the top of this page, or use the Related Skills and comparison links further down to weigh svelte-markdown against similar tools.

评论 (0)

暂无评论,成为第一个分享想法的人!

ECC

by affaan-m

10

The agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.

242,21936,702JavaScript
AI 智能体ai-agentsanthropicclaude-code
查看详情
15

An agentic skills framework & software development methodology that works.

234,96620,863Shell
AI 智能体ai-agentsbrainstorming
查看详情

hermes-agent

by NousResearch

10

The agent that grows with you

234,43747,175Python
AI 智能体ai-agentsagent-orchestration
查看详情

The agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.

185,94028,768JavaScript
AI 智能体ai-agentsanthropicclaude-code
查看详情

cc-switch

by farion1231

3

A cross-platform desktop All-in-One assistant for Claude Code, Codex, OpenCode, OpenClaw, Grok Build & Hermes Agent. Only official website: ccswitch.io

128,8688,826Rust
AI 智能体claude-codeai-tools
查看详情

claude-code

by anthropics

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

120,03119,897Shell
AI 智能体
查看详情

开发者还喜欢

基于喜欢此 Skill 的开发者投票和收藏

ECC

by affaan-m

10

The agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.

242,21936,702JavaScript
AI 智能体ai-agentsanthropicclaude-code
查看详情
15

An agentic skills framework & software development methodology that works.

234,96620,863Shell
AI 智能体ai-agentsbrainstorming
查看详情

hermes-agent

by NousResearch

10

The agent that grows with you

234,43747,175Python
AI 智能体ai-agentsagent-orchestration
查看详情

n8n

by n8n-io

12

Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.

201,88160,308TypeScript
MCP 服务器apisai-tools
查看详情

The agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.

185,94028,768JavaScript
AI 智能体ai-agentsanthropicclaude-code
查看详情

cc-switch

by farion1231

3

A cross-platform desktop All-in-One assistant for Claude Code, Codex, OpenCode, OpenClaw, Grok Build & Hermes Agent. Only official website: ccswitch.io

128,8688,826Rust
AI 智能体claude-codeai-tools
查看详情