UNPKG

@humanspeak/svelte-markdown

Version:

Markdown and HTML renderer for Svelte 5 โ€” built for rendering streaming AI agent output from Claude Code, ChatGPT, and agentic workflows. XSS-safe defaults, streaming-aware sanitization, token caching, TypeScript types, and Svelte 5 runes.

940 lines (693 loc) โ€ข 36.8 kB
# @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](https://img.shields.io/npm/v/@humanspeak/svelte-markdown.svg)](https://www.npmjs.com/package/@humanspeak/svelte-markdown) [![Build Status](https://github.com/humanspeak/svelte-markdown/actions/workflows/npm-publish.yml/badge.svg)](https://github.com/humanspeak/svelte-markdown/actions/workflows/npm-publish.yml) [![Coverage Status](https://coveralls.io/repos/github/humanspeak/svelte-markdown/badge.svg?branch=main)](https://coveralls.io/github/humanspeak/svelte-markdown?branch=main) [![License](https://img.shields.io/npm/l/@humanspeak/svelte-markdown.svg)](https://github.com/humanspeak/svelte-markdown/blob/main/LICENSE) [![Downloads](https://img.shields.io/npm/dm/@humanspeak/svelte-markdown.svg)](https://www.npmjs.com/package/@humanspeak/svelte-markdown) [![CodeQL](https://github.com/humanspeak/svelte-markdown/actions/workflows/codeql.yml/badge.svg)](https://github.com/humanspeak/svelte-markdown/actions/workflows/codeql.yml) [![Install size](https://packagephobia.com/badge?p=@humanspeak/svelte-markdown)](https://packagephobia.com/result?p=@humanspeak/svelte-markdown) [![Code Style: Trunk](https://img.shields.io/badge/code%20style-trunk-blue.svg)](https://trunk.io) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Types](https://img.shields.io/npm/types/@humanspeak/svelte-markdown.svg)](https://www.npmjs.com/package/@humanspeak/svelte-markdown) [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/humanspeak/svelte-markdown/graphs/commit-activity) ## 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) - โšก 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 ```bash npm i -S @humanspeak/svelte-markdown ``` Or with your preferred package manager: ```bash pnpm add @humanspeak/svelte-markdown yarn add @humanspeak/svelte-markdown ``` ## Basic Usage ```svelte <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 default** โ€” `javascript:` URLs and `on*` handlers stripped from agent output before render, no opt-in required (see [Security](#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](#custom-html-tags)) ```svelte <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](https://x.com/trq212/status/2052809885763747935). For the full streaming API (offset chunks, reset, websocket patterns), see [LLM Streaming](#llm-streaming) below. ## TypeScript Support The package is written in TypeScript and includes full type definitions: ```typescript 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. ```ts 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`): ```ts 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: ```ts 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: ```ts 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): ```ts 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: ```ts import { allowRenderersOnly } from '@humanspeak/svelte-markdown' const md = allowRenderersOnly(['paragraph', 'link']) ``` Exclude just link; keep others as defaults: ```ts import { excludeRenderersOnly } from '@humanspeak/svelte-markdown' const md = excludeRenderersOnly(['link']) ``` Disable all markdown renderers (except `html`) quickly: ```ts import { buildUnsupportedRenderers } from '@humanspeak/svelte-markdown' const md = buildUnsupportedRenderers() ``` ### Combine HTML and Markdown helpers You can combine both maps in `renderers` for `SvelteMarkdown`. ```svelte <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: ```svelte <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](https://github.com/humanspeak/svelte-markdown/tree/main/src/lib/renderers) 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: ```svelte <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: ```svelte <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:** ```svelte <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:** ```svelte <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](https://marked.js.org/using_advanced#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: ```bash npm install katex ``` **Default delimiter set** (mirrors KaTeX's own [`auto-render`](https://katex.org/docs/autorender.html) 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:** ```svelte <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): ```svelte <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: ```bash npm install mermaid ``` Then use the built-in helpers โ€” no boilerplate needed: ```svelte <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: ```svelte <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](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts). Five alert types are supported: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, and `CAUTION`. ```svelte <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: ```svelte <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. ```svelte <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. ### 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`, `level` for alerts). See the [full documentation](https://markdown.svelte.page/docs/advanced/marked-extensions) and [interactive demo](https://markdown.svelte.page/examples/marked-extensions). ### 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 | <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: ```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. ```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 }) ``` ### 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: ```svelte <!-- EagerImage.svelte --> <script lang="ts"> let { href = '', title = undefined, text = '' } = $props() </script> <img src={href} {title} alt={text} loading="eager" /> ``` ```svelte <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. ```svelte <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: ```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. 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: ```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`. Changing the `source` prop also resets the imperative buffer, seeds a new baseline value, and unlocks the input mode. Appending directly to `source` is still supported: ```svelte <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): | Metric | Standard Mode | Streaming Mode | | -------------- | :-----------: | :------------: | | Average render | ~3.6ms | ~1.6ms | | Peak render | ~21ms | ~10ms | | Dropped frames | 0 | 0 | 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. **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](https://markdown.svelte.page/docs/advanced/llm-streaming) and [interactive demo](https://markdown.svelte.page/examples/llm-streaming). ## 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: | Element | Description | | -------- | -------------------- | | `div` | Division element | | `span` | Inline container | | `table` | HTML table structure | | `thead` | Table header group | | `tbody` | Table body group | | `tr` | Table row | | `td` | Table data cell | | `th` | Table header cell | | `ul` | Unordered list | | `ol` | Ordered list | | `li` | List item | | `code` | Code block | | `em` | Emphasized text | | `strong` | Strong text | | `a` | Anchor/link | | `img` | Image | You can customize HTML rendering by providing your own components: ```typescript 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: ```svelte <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 | Prop | Type | Description | | ------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------ | | source | `string \| Token[]` | Markdown content or pre-parsed tokens | | streaming | `boolean` | Enable incremental rendering for LLM streaming | | renderers | `Partial<Renderers>` | Custom component overrides | | options | `SvelteMarkdownOptions` | Marked parser configuration | | isInline | `boolean` | Toggle inline parsing mode | | extensions | `MarkedExtension[]` | Third-party marked extensions (e.g., KaTeX math) | | sanitizeUrl | `SanitizeUrlFn` | URL sanitizer applied before render. Defaults to `defaultSanitizeUrl` (http/https/mailto/tel/relative) | | sanitizeAttributes | `SanitizeAttributesFn` | Attribute 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](#helper-utilities-for-allowdeny-strategies)). 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. ## License MIT ยฉ [Humanspeak, Inc.](LICENSE) ## Credits Made with โค๏ธ by [Humanspeak](https://humanspeak.com)