svelte-markdown Svelte Themes

Svelte Markdown

๐Ÿ“ Fast, lightweight Markdown renderer component for Svelte applications with full CommonMark support

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

Features

  • ๐Ÿ”’ Secure HTML parsing via HTMLParser2 with XSS protection
  • ๐Ÿš€ 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)
  • ๐Ÿ–ผ๏ธ 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} />

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 third-party marked extensions via the extensions prop. The component handles registering tokenizers internally โ€” you just provide renderers for the custom token types.

KaTeX Math Rendering

npm install marked-katex-extension katex

Component renderer approach:

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

    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/[email protected]/dist/katex.min.css" crossorigin="anonymous" />
</svelte:head>

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

Where KatexRenderer.svelte is:

<script lang="ts">
    import katex from 'katex'

    interface Props {
        text: string
        displayMode?: boolean
    }
    const { text, displayMode = false }: Props = $props()

    const html = $derived(katex.renderToString(text, { throwOnError: false, displayMode }))
</script>

{@html html}

Snippet override approach (no separate component file needed):

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

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

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

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: marked-katex-extension 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 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 })

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

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:

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

Prop Type Description
source string | Token[] Markdown content or pre-parsed tokens
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)

Security

This package takes a defense-in-depth approach to security:

  • Secure HTML parsing - All HTML is parsed through HTMLParser2's streaming parser rather than innerHTML, preventing script injection
  • XSS protection - HTML entities are safely handled; malicious markdown injection is neutralized during parsing
  • Granular HTML control - Use allowHtmlOnly() / excludeHtmlOnly() to restrict which HTML tags are rendered (see Helper utilities)
  • 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
  • No built-in sanitizer - By design, the package does not bundle a sanitizer. Integrate your own (e.g., DOMPurify) if you accept untrusted input

License

MIT ยฉ Humanspeak, Inc.

Credits

Made with โค๏ธ by Humanspeak

Top categories

Loading Svelte Themes