Markdown Rendering
AI messages in @threadplane/chat can render full markdown -- headings, code blocks, tables, lists, blockquotes, math, HTML nodes, and inline formatting. Use <chat-streaming-md> for the live node-based renderer. Use renderMarkdown() only when you need a small sanitized-HTML helper in a custom template.
How It Works
The markdown pipeline has two stages:
- Streaming renderer:
<chat-streaming-md>parses markdown into node keys and renders those keys throughcacheplaneMarkdownViews. - Sanitized HTML helper:
renderMarkdown()converts markdown text to sanitized HTML using the requiredmarkedpeer dependency. Ifmarkedcannot be loaded at runtime, it falls back to escaped plain text with<br>newline conversion.
The renderMarkdown() Function
import { renderMarkdown } from '@threadplane/chat';
import { DomSanitizer } from '@angular/platform-browser';
// In a component:
private sanitizer = inject(DomSanitizer);
renderMd(content: string): SafeHtml {
return renderMarkdown(content, this.sanitizer);
}Signature:
function renderMarkdown(content: string, sanitizer: DomSanitizer): SafeHtml| Parameter | Type | Description |
|---|---|---|
content | string | Raw markdown text to render |
sanitizer | DomSanitizer | Angular's DOM sanitizer for XSS protection |
Returns: SafeHtml -- sanitized HTML that can be bound via [innerHTML].
Behavior:
- Parses markdown to HTML with
marked, sanitizes it through Angular'sSecurityContext.HTML, then marks the result as trusted. - If the dynamic
markedimport fails at runtime, escapes HTML entities (&,<,>) and converts newlines to<br>tags as a defensive fallback.
The marked library is loaded via a dynamic import('marked') at module initialization time. This means it does not block initial bundle loading and resolves before the first render in most cases.
Using Markdown in Custom Components
For custom message templates, prefer <chat-streaming-md> so you get the same node registry, math, citation, table, and code-block behavior as the built-in chat components:
import {
ChatStreamingMdComponent,
ChatMessageListComponent,
MessageTemplateDirective,
markdownDocument,
type Message,
type StreamingMarkdownDocument,
} from '@threadplane/chat';
@Component({
selector: 'app-chat-view',
standalone: true,
imports: [ChatMessageListComponent, MessageTemplateDirective, ChatStreamingMdComponent],
template: `
<chat-message-list [agent]="chatRef">
<ng-template chatMessageTemplate="ai" let-message>
<chat-streaming-md [document]="documentFor(message)" />
</ng-template>
</chat-message-list>
`,
})
export class ChatViewComponent {
// chatRef = injectAgent(); // with provideAgent({...}) in the component's providers
documentFor(message: Message): StreamingMarkdownDocument {
const content = typeof message.content === 'string'
? message.content
: message.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('');
return markdownDocument(content, message.delivery);
}
}If you only need sanitized HTML, call renderMarkdown() directly and provide your own styling for the host element:
import { Component, inject } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { renderMarkdown } from '@threadplane/chat';
@Component({
selector: 'app-markdown-html',
template: `<div class="markdown-body" [innerHTML]="renderMd(content)" />`,
})
export class MarkdownHtmlComponent {
private sanitizer = inject(DomSanitizer);
content = '';
renderMd(content: string | unknown) {
if (typeof content !== 'string') return '';
return renderMarkdown(content, this.sanitizer);
}
}Streaming Markdown with chat-streaming-md
<chat-streaming-md> is the component that renders AI message content token-by-token using the node-based rendering pipeline. It requires one atomic [document] input with this shape:
interface StreamingMarkdownDocument {
generation: string;
phase: 'streaming' | 'complete';
content: string;
}Use the exported markdownDocument(content, delivery) helper to derive it from message content and the adapter-owned Message.delivery. Within one generation, streaming content must be append-only. Complete the same generation once, then leave its content unchanged. A retry or replacement starts a new generation. The component uses those guarantees to preserve parser and rendered-node identity while chunks arrive.
It resolves each markdown node type against MARKDOWN_VIEW_REGISTRY — a chat-internal DI token exported from @threadplane/chat.
By default the component provides cacheplaneMarkdownViews (the full 22-node registry) on its own component injector. You can override this at two levels:
- App-wide — provide a custom registry in your root or feature providers.
- Per-instance — pass a
ViewRegistryvia the[viewRegistry]input; the component uses that value instead of the DI tree.
Overriding Markdown Components
App-wide override
To replace a node-type renderer for every <chat-streaming-md> in your app, provide a custom MARKDOWN_VIEW_REGISTRY in your application config:
import { ApplicationConfig } from '@angular/core';
import { MARKDOWN_VIEW_REGISTRY, cacheplaneMarkdownViews } from '@threadplane/chat';
import { overrideViews } from '@threadplane/render';
import { MyCodeBlockComponent } from './my-code-block.component';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: MARKDOWN_VIEW_REGISTRY,
useValue: overrideViews(cacheplaneMarkdownViews, {
'code-block': MyCodeBlockComponent,
}),
},
],
};overrideViews(base, overrides) replaces every key listed in overrides and preserves all other entries from base. Import it from @threadplane/render (chat does not re-export it).
Use overrideViews when replacing an existing node type. Use withViews when adding a brand-new node type that cacheplaneMarkdownViews does not yet cover — withViews is additive-only and the base registry wins on conflicts. See the render views API for full signatures.
Per-instance override
Pass a ViewRegistry directly to a single <chat-streaming-md> via its [viewRegistry] input. The component uses the provided value and ignores the DI tree for that instance:
import { Component } from '@angular/core';
import {
ChatStreamingMdComponent,
cacheplaneMarkdownViews,
markdownDocument,
staticDelivery,
} from '@threadplane/chat';
import { overrideViews } from '@threadplane/render';
import { MyCodeBlockComponent } from './my-code-block.component';
@Component({
selector: 'app-custom-chat',
standalone: true,
imports: [ChatStreamingMdComponent],
template: `
<chat-streaming-md [document]="document" [viewRegistry]="myRegistry" />
`,
})
export class CustomChatComponent {
document = markdownDocument(
'# Custom renderer\n\nThis document is already complete.',
staticDelivery('custom-preview'),
);
myRegistry = overrideViews(cacheplaneMarkdownViews, {
'code-block': MyCodeBlockComponent,
});
}Node-Type Reference
cacheplaneMarkdownViews covers every node type emitted by @cacheplane/partial-markdown. Use these keys when calling overrideViews or withViews.
The most common mistake is providing 'code' as an override key — it does not match anything in the registry. The correct key for fenced code blocks is 'code-block'.
| Key | Description |
|---|---|
'document' | Root node wrapping the entire parsed document |
'paragraph' | Block-level paragraph (<p>) |
'heading' | Heading element (<h1> through <h6>) |
'blockquote' | Block-level quotation (<blockquote>) |
'list' | Ordered or unordered list (<ol> / <ul>) |
'list-item' | Individual list item (<li>) |
'code-block' | Fenced code block (<pre><code>) |
'thematic-break' | Horizontal rule (<hr>) |
'text' | Inline text run |
'emphasis' | Italic emphasis (<em>) |
'strong' | Bold emphasis (<strong>) |
'strikethrough' | Strikethrough text (<del>) |
'inline-code' | Inline code span (<code>) |
'math-inline' | Inline math |
'math-display' | Display math block |
'html-inline' | Inline HTML node |
'html-block' | Block HTML node |
'link' | Hyperlink (<a>) |
'autolink' | Auto-detected URL or email link |
'image' | Image (<img>) |
'soft-break' | Soft line break (space or newline within a paragraph) |
'hard-break' | Hard line break (<br>) |
'citation-reference' | In-text citation reference rendered by the chat pipeline |
'table' | Table container (<table>) |
'table-row' | Table row (<tr>) |
'table-cell' | Table header or data cell (<th> / <td>) |
The built-in 'table' renderer emits native <tr>, <th>, and <td> elements directly so streamed rows stay attached to the same browser table. Override the 'table' key when you need to change the built-in table structure. The 'table-row' and 'table-cell' keys remain available for custom renderers or direct row/cell rendering, but the default table renderer does not dispatch its child rows through those registry entries.
Theming Markdown Components
All built-in markdown view components consume the same --tplane-chat-* and --a2ui-* CSS custom properties as the rest of the chat UI. No extra tokens are needed — changing the active theme automatically re-styles markdown output. See the chat theming guide for the full token reference.
Defensive Plain-Text Fallback
marked is a required peer dependency for supported @threadplane/chat installs:
npm install markedIf the dynamic import('marked') still fails at runtime, renderMarkdown() falls back to escaped plain text with line breaks preserved. Treat that fallback as a resilience path, not as the recommended installation mode; the built-in chat experience expects marked to be present for rich markdown.