@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; renderMarkdown and parseMarkdown expose the lower-level render/parse steps.
  • Pluggable rendering. A Renderers map lets you override any mdast node type (headings, blockquotes, code, …) while inheriting defaultRenderers for everything else; mergeRenderers composes a partial override set over the built-ins.
  • Safe by default. sanitizeUrl / resolveUrl guard href/src values; raw HTML handling is opt-in.
  • Streaming-friendly. toKeyedBlocks / blockSource key 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 string source → 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 through each, 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
}

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