@llui/markdown
Turns a Markdown string into real LLui DOM. markdown(source) parses to an mdast AST and renders it through LLui's authoring helpers as live reactive nodes — there is no virtual DOM and no dangerouslySetInnerHTML. Bind it to a reactive source signal and the preview re-renders as the string changes; top-level blocks are content-hash-keyed, so a growing or streaming document reuses the DOM of unchanged blocks instead of rebuilding.
This is the render-only counterpart to @llui/markdown-editor. Reach for @llui/markdown when you want to display Markdown (docs, previews, chat transcripts, streamed model output); reach for the editor when you want a WYSIWYG editing surface.
pnpm add @llui/markdown @llui/dom
@llui/dom is a peer dependency.
What it gives you
- Markdown in, LLui DOM out.
markdown(source, options?)builds real reactive nodes;renderMarkdownandparseMarkdownexpose the lower-level render/parse steps. - Pluggable rendering. A
Renderersmap lets you override any mdast node type (headings, blockquotes, code, …) while inheritingdefaultRenderersfor everything else;mergeRendererscomposes a partial override set over the built-ins. - Safe by default.
sanitizeUrl/resolveUrlguardhref/srcvalues; raw HTML handling is opt-in. - Streaming-friendly.
toKeyedBlocks/blockSourcekey top-level blocks by content hash so incremental updates reconcile rather than rebuild.
API
Functions
blockSource()
The block's source text (via mdast position offsets), or a structural fallback.
function blockSource(node: RootContent, source: string): string
collectDefinitions()
Walk the tree and collect every link/image reference definition, keyed by
lowercased identifier (so linkReference/imageReference nodes can resolve).
function collectDefinitions(root: Root): Map<string, Definition>
createMarkdown()
Build a reactive Markdown view bound to a specific parser.
- Plain
stringsource → parsed once, rendered statically. Signal<string>source → re-parsed on change; top-level blocks are keyed by a content hash (folding in the reference definitions each block resolves) and rendered througheach, so unchanged earlier blocks keep their DOM and only the changing tail (and appended / newly-resolved blocks) rebuild. This makes streaming / growing Markdown (e.g. LLM output) cheap to render.
function createMarkdown(parse: ParseFn)
incrementalParse()
Parse source incrementally against cache (the previous source + tree), or
fully when no safe reuse boundary exists. The returned tree is structurally
identical to parse(source) — reuse only ever changes which node OBJECTS are
shared with the previous tree (so their keys, and thus their DOM, survive).
function incrementalParse(cache: ParseCache | undefined, source: string, parse: (src: string) => Root, allowPrefixReuse = true): IncrementalResult
keyingHashComputations()
Test/benchmark hook: reads (and optionally resets) the from-scratch hash count.
function keyingHashComputations(reset = false): number
makeContext()
Build the context renderers receive: render dispatches one node through the
merged registry, renderChildren recurses, definitions resolves references.
function makeContext(options: ResolvedOptions, definitions: ReadonlyMap<string, Definition>): RenderContext
mergeRenderers()
Merge user overrides over the built-in defaults into a uniform registry.
function mergeRenderers(user?: Renderers): ResolvedRenderers
parseMarkdown()
Parse Markdown source into an mdast {@link Root}. GFM is on unless
opts.gfm === false. Extra extensions/mdastExtensions are appended.
function parseMarkdown(src: string, opts: MarkdownOptions = {}): Root
renderMarkdown()
Render an already-parsed mdast {@link Root} to LLui DOM (no wrapper element). Returns the rendered top-level blocks. Parser-agnostic (takes an mdast tree).
function renderMarkdown(root: Root, opts: MarkdownOptions = {}): Renderable
resolveOptions()
function resolveOptions(opts: MarkdownOptions = {}): ResolvedOptions
resolveUrl()
Resolve a link/image URL through transformLink (if any) then sanitize it.
Returns the final URL, or null if the link/image should be dropped.
function resolveUrl(url: string, node: Link | Image | LinkReference | ImageReference, options: ResolvedOptions): string | null
sanitizeUrl()
Returns the URL unchanged if its scheme is on allowedProtocols (or it is a
relative/anchor/query URL — always safe), otherwise null.
Mirrors micromark's sanitizeUri: a scheme only "counts" when its colon
precedes any /, ?, or #. Tab/CR/LF are stripped and leading control/space
chars ignored first, the way a browser does — so java\tscript: or a leading
control char cannot hide a dangerous scheme.
allowedProtocols defaults to {@link defaultAllowedProtocols}.
export declare function sanitizeUrl(url: string, allowedProtocols?: readonly string[]): string | null;
toKeyedBlocks()
Derive a stable, unique-per-render key for each top-level block. Identical block
source (AND identical resolved references) ⇒ identical base key; duplicate keys —
whether from identical content or a user keyOf — get a #n suffix so the outer
each never receives a colliding key (which would corrupt its keyed reconcile).
function toKeyedBlocks(root: Root, source: string, options: ResolvedOptions, definitions: ReadonlyMap<string, Definition>): KeyedBlock[]
Types
NodeRenderer
A node renderer turns one mdast node into Renderable LLui DOM. It receives the node and a {@link RenderContext} for recursing into children / sibling nodes.
export type NodeRenderer<N extends Node = Node> = (node: N, ctx: RenderContext) => Renderable
ParseFn
A Markdown → mdast parser (GFM or CommonMark). Injected into {@link createMarkdown}.
export type ParseFn = (src: string, opts?: MarkdownOptions) => Root
Renderers
Per-node-type render overrides, merged OVER the built-in {@link defaultRenderers}.
Known mdast types are precisely typed; the string index admits custom node types.
The index value is typed NodeRenderer<never> on purpose: a (node: Heading) => …
renderer is assignable to (node: never) => … (parameters are contravariant, and
never is a subtype of every type), so the precise per-type renderers and custom
renderers coexist without the variance conflict a NodeRenderer<Node> index would
cause. Author custom renderers with an explicit param type ((node: MyNode) => …).
export type Renderers = {
[K in Nodes['type']]?: NodeRenderer<Extract<Nodes, { type: K }>>
} & {
[type: string]: NodeRenderer<never> | undefined
}
TransformLink
A URL the renderer is about to emit (link href / image src), with the source
node. Return a rewritten URL, or null to drop the link/image entirely.
export type TransformLink = (
href: string,
node: Link | Image | LinkReference | ImageReference,
) => string | null
Interfaces
IncrementalResult
Result of one (incremental or full) parse: the tree plus the cache to thread
into the next update. reused is the number of prefix blocks reused (0 = full
parse) — used only for dev diagnostics.
export interface IncrementalResult {
readonly root: Root
readonly cache: ParseCache
readonly reused: number
}
KeyedBlock
export interface KeyedBlock {
/** Reconcile identity for the outer keyed list (from `keyOf`, else content-based). */
key: string | number
/** Content identity — changes iff the block's source (or the reference
* definitions it resolves) changes. Drives in-place row rebuilds when a custom
* `keyOf` gives blocks stable identity. */
hash: string
node: Nodes
}
MarkdownOptions
export interface MarkdownOptions {
/** Enable GitHub Flavored Markdown (tables, strikethrough, task lists,
* autolinks, footnotes). Default `true`. */
gfm?: boolean
/** Per-node-type render overrides, merged over the built-in defaults. */
renderers?: Renderers
/** Extra micromark syntax extensions (custom block/inline syntax). */
extensions?: FromMarkdownOptions['extensions']
/** Extra mdast extensions matching the syntax extensions above. */
mdastExtensions?: FromMarkdownOptions['mdastExtensions']
/** Opt in to incremental (tail-reuse) parsing for a REACTIVE source even when
* custom `extensions`/`mdastExtensions` are present. Off by default: the
* incremental parser's seal invariant is only proven for CommonMark + GFM, so a
* custom extension whose syntax can retro-reclassify an earlier block (crossing a
* blank-line seal) would leave a stale prefix. Set `true` ONLY when your
* extensions are seal-safe (no cross-block/document-global effects). Ignored when
* no custom extensions are configured (built-in reuse always applies). */
sealSafeExtensions?: boolean
/** Sanitizer for raw HTML nodes. Raw HTML is **dropped by default**
* (safe for untrusted/LLM content). To render it, supply a function
* that takes the raw HTML and returns a sanitized string (e.g. wrap
* DOMPurify); the result is injected verbatim. There is intentionally
* no "render raw HTML unsanitized" switch — that would be an XSS sink. */
sanitizeHtml?: (html: string) => string
/** URL schemes permitted in links/images. A URL with no scheme (relative,
* anchor, query) is always allowed. Default `['http','https','mailto','tel']`. */
allowedProtocols?: string[]
/** Rewrite or drop link/image URLs before sanitization. */
transformLink?: TransformLink
/** Class applied to the root wrapper element. Default `'markdown-body'`. */
class?: string
/** Override the key derived for each top-level block (controls reuse during
* reactive/streaming updates). Default: a content hash of the block's source. */
keyOf?: (node: Nodes, index: number) => string | number
}
ParseCache
Old source + its parsed tree, threaded across reactive updates.
export interface ParseCache {
readonly source: string
readonly root: Root
}
RenderContext
Passed to every {@link NodeRenderer}: recurse, resolve references, read options.
export interface RenderContext {
/** Render a single node via the registry (unknown types render nothing). */
render: (node: Node) => Renderable
/** Render all children of a parent node, flattened. */
renderChildren: (parent: { children: readonly Node[] }) => Renderable
/** Link/image reference definitions collected from the whole document, keyed
* by lowercased identifier. */
definitions: ReadonlyMap<string, Definition>
/** The resolved options. */
options: ResolvedOptions
}
ResolvedOptions
Fully-resolved options with defaults applied — what renderers see on ctx.
export interface ResolvedOptions {
gfm: boolean
renderers: ResolvedRenderers
extensions: FromMarkdownOptions['extensions']
mdastExtensions: FromMarkdownOptions['mdastExtensions']
sealSafeExtensions: boolean
sanitizeHtml: ((html: string) => string) | undefined
allowedProtocols: string[]
transformLink: TransformLink | undefined
class: string
keyOf: ((node: Nodes, index: number) => string | number) | undefined
}
Constants
defaultAllowedProtocols
The schemes permitted by default in links (and, via markdown, images). Relative URLs (no scheme) are always allowed regardless of this list. This is the shared baseline every consumer builds on instead of hand-rolling a divergent allowlist.
const defaultAllowedProtocols: readonly string[]
defaultRenderers
const defaultRenderers: BuiltinRenderers
markdown
Reactive Markdown view (CommonMark + GFM). Composes like text() — returns a
Mountable. For a GFM-free build, import from @llui/markdown/commonmark.
const markdown