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.
- π 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
extensionsprop (e.g., KaTeX math, alerts) - π¨ Opt-in syntax highlighting with one
HighlightedCoderenderer 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
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, orrows. - The default
coderenderer emits one text node per line, socode.firstChildis the first line only.textContentandinnerHTMLare 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.
2. Code that reads firstChild or childNodes of rendered <code> elements or of
the markdown container.
3. Direct IncrementalParser usage.
4. Tests that snapshot streamed output mid-stream.
List each place that needs a change, explain why using the guide, and propose
the smallest fix.
Requires Svelte 5 and Node.js 22 or newer for package tooling.
npm i -S @humanspeak/svelte-markdownOr with your preferred package manager:
pnpm add @humanspeak/svelte-markdown
yarn add @humanspeak/svelte-markdown<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} />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 andon*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
<tool-call>,<thinking>, or your own design-system tags to your own components viarenderers.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.
The package is written in TypeScript and includes full type definitions:
import type {
Renderers,
Token,
TokensList,
SvelteMarkdownOptions,
SvelteMarkdownProps,
MarkedExtension
} from '@humanspeak/svelte-markdown'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
rendererKeysintentionally excludeshtml. UsehtmlRendererKeysfor HTML tag overrides.UnsupportedandUnsupportedHTMLdisplay suppressed markup as escaped text. They do not remove its content; use a custom renderer if you want to hide it entirely.
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 usesUnsupportedHTML.allowHtmlOnly(allowed): enable only the provided tags; others useUnsupportedHTML.- Accepts tag names like
'strong'or tuples like['div', MyDiv]to plug in custom components.
- Accepts tag names like
excludeHtmlOnly(excluded, overrides?): disable only the listed tags (mapped toUnsupportedHTML), with optional overrides for non-excluded tags using tuples.
- Markdown helpers (non-HTML)
buildUnsupportedRenderers(): returns a map where all markdown renderers (excepthtml) useUnsupported.allowRenderersOnly(allowed): enable only the provided markdown renderer keys; others useUnsupported.- Accepts keys like
'paragraph'or tuples like['paragraph', MyParagraph]to plug in custom components.
- Accepts keys like
excludeRenderersOnly(excluded, overrides?): disable only the listed markdown renderer keys, with optional overrides for non-excluded keys using tuples.
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()
}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()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} />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.
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, 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>- Container renderers (paragraph, heading, blockquote, list, etc.) receive a
childrensnippet 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.paragraphcomponent are provided, the snippet wins
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<string, string | number | boolean | undefined>, children?: Snippet }.
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.
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 <Tooltip>, <TOOLTIP> and <tooltip> 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.
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.
The package includes built-in markedKatex and KatexRenderer helpers. Install katex as an optional peer dependency and load its CSS:
npm install katexDefault 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 (one snippet handles inline and display math):
<script lang="ts">
import SvelteMarkdown from '@humanspeak/svelte-markdown'
import {
markedKatex,
KatexRenderer,
type KatexSnippetProps
} from '@humanspeak/svelte-markdown/extensions'
import 'katex/dist/katex.min.css'
</script>
{#snippet math({ text, displayMode }: KatexSnippetProps)}
<KatexRenderer {text} {displayMode} />
{/snippet}
<SvelteMarkdown
source={`Euler's identity: \\(e^{i\\pi} + 1 = 0\\)`}
extensions={[markedKatex()]}
inlineKatex={math}
blockKatex={math}
/>KatexSnippetProps includes text: string, displayMode: boolean, and optional streamingText: StreamingTextMetadata. KatexSnippetOverrides adds optional inlineKatex and blockKatex snippets to wrapper props without accepting arbitrary prop names. Both types are exported from @humanspeak/svelte-markdown/extensions and @humanspeak/svelte-markdown/extensions/katex.
A wrapper that supplies its own KaTeX extension can forward the snippets with:
<script lang="ts">
import SvelteMarkdown, { type SvelteMarkdownProps } from '@humanspeak/svelte-markdown'
import { markedKatex, type KatexSnippetOverrides } from '@humanspeak/svelte-markdown/extensions'
type Props = Omit<SvelteMarkdownProps, 'extensions'> & KatexSnippetOverrides
let props: Props = $props()
const extensions = [markedKatex()]
</script>
<SvelteMarkdown {...props} {extensions} />The package includes built-in markedMermaid and MermaidRenderer helpers for Mermaid diagram support. Install mermaid as an optional peer dependency:
npm install mermaidMermaid 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.
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>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 <sup><a href="#fn-{id}">{id}</a></sup> and FootnoteSection renders an <ol> 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.
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/highlightImport 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```'
</script>
<svelte:head>{@html `<style>${themeCss}</style>`}</svelte:head>
<SvelteMarkdown {source} renderers={{ code: HighlightedCode }} />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.
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, alertType for alerts). Marked HTML renderer functions do not replace Svelte renderers; provide a component or snippet for each custom token type.
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.
All snippet prop types are exported for use in external components:
import type {
ParagraphSnippetProps,
HeadingSnippetProps,
LinkSnippetProps,
CodeSnippetProps,
HtmlSnippetProps,
SnippetOverrides,
HtmlSnippetOverrides
} from '@humanspeak/svelte-markdown'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> |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>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.
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.
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, andhasTokensare the supported token API. The rawget()/set()methods inherited fromMemoryCachenow operate on the wrapped{ source, tokens }entry shape, not bare token arrays.
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:
<!-- 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} />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.
<script lang="ts">
import SvelteMarkdown from '@humanspeak/svelte-markdown'
import { FadeWords } from '@humanspeak/svelte-markdown/streaming/motion'
let source = $state('')
let streamId = $state(0)
</script>
<SvelteMarkdown {source} {streamId} streaming streamingText renderers={{ rawtext: FadeWords }} />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.
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.
<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: '...' })
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={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.
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:
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:
stringchunks: 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.
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:
streamId={message.id}β declarative; works even when the instance is recycled.{#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.markdown.resetStream()β imperative; call it before the firstwriteChunk()of the new stream.
Note: if you reset by changing
sourceand callwriteChunk()in the same tick, the write lands before the prop-driven reset βwriteChunk()is synchronous while the reset runs in an effect. PreferstreamIdorresetStream(), 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 (2026-09-28, headless Chromium, ~24 KB corpora streamed at 32 characters per animation frame, median of five paired runs; main-thread work per frame including a forced layout):
| Scenario | Avg work per frame | p95 per frame | Frames over 16.7 ms |
|---|---|---|---|
| Mixed prose | ~2.4 ms | 4.3β4.5 ms | 0 of 765 |
| Mixed prose, 4 updates per frame | ~2.9 ms | 5.4β5.5 ms | 0 of 192 |
| One open ~200-item list | ~4.7 ms | 7.3β7.9 ms | 1 of 754 |
| One open code fence | ~2.1 ms | 3.3β3.4 ms | 0 of 753 |
Milliseconds are machine-specific; reproduce with pnpm perf:stream-compare. Streamed output is checked against a one-shot parse at every sampled frame, and by a seeded fuzz suite that splits random documents at random chunk boundaries.
With streaming enabled, the component bypasses the document token cache, batches appended chunks around animation frames, and reuses unchanged tokens to limit DOM updates. Tail-window parsing is used when the document and parsing configuration allow it; unsupported configurations fall back to full parsing for correctness. Offset patches are applied immediately.
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.
Async parsing: extensions that declare async: true disable streaming and log a warning. writeChunk() and resetStream() are unavailable in that configuration. The built-in markedMermaid() tokenizer is synchronous; its browser renderer can render asynchronously without disabling streaming.
See the full streaming documentation and interactive demo.
SvelteMarkdown drives its streaming mode with the exported IncrementalParser. Advanced consumers can use it directly to parse a growing document and learn which tokens changed:
import { IncrementalParser } from '@humanspeak/svelte-markdown'
const parser = new IncrementalParser({ gfm: true })
let buffer = '# Title\n\n'
parser.update(buffer)
const previous = buffer
buffer += 'Streamed paragraph'
const result = parser.update(buffer, previous) // `buffer` is known to start with `previous`update(source, appendsTo?) accepts the full accumulated source, re-lexes the appended tail when possible, and compares the result with the previous update. The optional appendsTo is a string you have already verified source starts with (typically your buffer before appending a chunk); when it is the previously parsed source, the parser skips its own full-length append check. Passing a string that source does not start with breaks parsing, so omit it when unsure.
The returned IncrementalUpdateResult contains:
tokensβ the full new token array.divergeAtβ index of the first root token that differs from the previous update.divergeOffsetβ source offset where that token begins, when known without scanning the stable prefix (otherwiseundefined).canReuseβ whether the firstdivergeAttoken objects can be reused as-is.reuseModeβ'prefix'(the firstdivergeAtroots are stable),'tree'(append-only, but a reference definition may have changed inline children anywhere, so compare the whole tree), or'none'(not append-only; replace the array).reusedPrefixCountβ leading roots oftokensthat are the same objects, at the same indices, as in the previous result (0 unless only the appended tail was re-lexed); a consumer that rendered the previous array unchanged can skip these indices.usedTailWindowβ whether this update re-lexed only the appended tail rather than the whole source.
text- Text within other elementsparagraph- 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 noderawtext- All other text that is going to be included in an object aboveescape- Backslash-escaped Markdown characters
Child tokens rendered inside list items and table cells receive only their own token fields; they do not inherit the parent list's or table's raw, text, or other fields through props.
For fine-grained styling:
orderedlistitem- Items in ordered listsunorderedlistitem- Items in unordered lists
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 |
This table shows a selection of the built-in tags. The exported htmlRendererKeys lists all built-in HTML renderer keys; custom tags can be registered too.
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
}Pass a parsed callback to inspect the current tokens after rendering updates in the browser:
<script lang="ts">
import SvelteMarkdown from '@humanspeak/svelte-markdown'
import type { Token, TokensList } from '@humanspeak/svelte-markdown'
const source = '# Hello'
const handleParsed = (tokens: Token[] | TokensList) => {
console.log('Parsed tokens:', tokens)
}
</script>
<SvelteMarkdown {source} parsed={handleParsed} />parsed runs in a Svelte effect, so it does not run during server-side rendering. Treat the supplied tokens as read-only: they may share objects with the parser cache or previous streaming updates. When the callback is omitted, its effect skips token updates.
| Prop | Type | Description |
|---|---|---|
| source | string | Token[] |
Markdown content or pre-parsed tokens |
| streaming | boolean |
Enable incremental rendering for LLM streaming |
| streamId | string | number |
Identity of the current stream. Changing it resets the streaming buffer, parser, and input-mode lock |
| renderers | Partial<Renderers> |
Custom component overrides |
| options | Partial<SvelteMarkdownOptions> |
Marked parser configuration |
| isInline | boolean |
Toggle inline parsing mode |
| extensions | MarkedExtension[] |
Built-in or third-party Marked extensions |
| parsed | (tokens: Token[] | TokensList) => void |
Optional browser callback; see Parsed callback |
| 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 |
Defaults: streaming and isInline are false; extensions is empty;
streamId and parsed are unset; renderer overrides are merged with the defaults.
writeChunk() and resetStream() require a string source and streaming={true}.
Pass parser options through the options prop:
<SvelteMarkdown {source} options={{ breaks: true, headerPrefix: 'article-' }} />| Option | Default | Description |
|---|---|---|
gfm |
true |
GitHub Flavored Markdown, including tables and task lists |
breaks |
false |
Render single newlines as line breaks when GFM is enabled |
pedantic |
false |
Use Marked's original Markdown parsing rules |
headerIds |
true |
Generate heading IDs with GitHub-style slugs |
headerPrefix |
'' |
Prefix generated heading IDs |
Heading renderer components and snippets receive the prepared id so they can
preserve heading links and duplicate-heading suffixes when overriding markup.
Pass a token array as source to render content you have already parsed. Token
arrays render immediately, including during server-side rendering, even when
async extensions are configured. The component does not apply parser extensions
to supplied tokens; apply them when you create the array.
String sources that use async extensions render after the component mounts and do not produce server-rendered content.
Default URL and attribute sanitizers run before link, image, and HTML props reach renderers or snippets. They provide XSS hardening, not complete HTML sanitization. Custom renderer code and extension-generated HTML remain your responsibility.
On by default:
- HTML parsing β Raw HTML is parsed with HTMLParser2 and rendered through Svelte components rather than inserted directly with
innerHTML. HTMLParser2 itself is not a sanitizer. - URL protocol allowlist (
defaultSanitizeUrl) β Markdown link/image URLs and the HTML attributeshref,src,action,formaction,cite,data, andposterare restricted tohttp:,https:,mailto:,tel:, and relative URLs.javascript:,vbscript:,data:, andblob:URIs are blocked (including mixed-case and leading-whitespace variants). - Event handler stripping (
defaultSanitizeAttributes) β Allon*attributes (e.g.onclick,onerror,onload) are removed. Thesrcdocattribute is also stripped to prevent iframe HTML injection. - No
<script>or<style>renderers β Both tags fall through toUnsupportedHTML, which renders them as visible escaped text (e.g.<script>...</script>) rather than executing or applying them.
Configurable controls:
- Custom sanitizers β Pass
sanitizeUrl/sanitizeAttributesprops to tighten or loosen the defaults. Use the exportedunsanitizedUrl/unsanitizedAttributespassthroughs 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 (onlyon*andsrcdocare 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,embedare rendered by default. The URL and attribute defaults still allow navigation, form submissions, and embedded content at arbitraryhttp(s)URLs. UseexcludeHtmlOnly(['iframe', 'form', 'embed'])to remove them.srcsetand other less common URL attributes are not sanitized. Only the attributes listed above pass throughsanitizeUrl. Provide a customsanitizeAttributesif 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.
Part of the Humanspeak family of runes-native Svelte 5 packages:
| Package | Description |
|---|---|
| @humanspeak/svelte-markdown β this package | Runtime markdown renderer for Svelte |
| @humanspeak/svelte-virtual-list | Virtual scrolling for Svelte |
| @humanspeak/svelte-motion | Framer Motion for Svelte 5 |
| @humanspeak/svelte-headless-table | Headless data tables for Svelte |
| @humanspeak/svelte-virtual-chat | Virtual chat viewport for Svelte 5 |
| @humanspeak/svelte-diff | Diff comparison for Svelte |
| @humanspeak/svelte-purify | HTML sanitisation for Svelte |
| @humanspeak/memory-cache | In-memory cache for TypeScript |
| @humanspeak/svelte-json-view-lite | JSON tree viewer for Svelte 5 |
| @humanspeak/svelte-scoped-props | Scoped class props for Svelte |
MIT Β© Humanspeak, Inc.
Made with β€οΈ by Humanspeak