Chat ยท Guides

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:

  1. Streaming renderer: <chat-streaming-md> parses markdown into node keys and renders those keys through cacheplaneMarkdownViews.
  2. Sanitized HTML helper: renderMarkdown() converts markdown text to sanitized HTML using the required marked peer dependency. If marked cannot 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
ParameterTypeDescription
contentstringRaw markdown text to render
sanitizerDomSanitizerAngular'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's SecurityContext.HTML, then marks the result as trusted.
  • If the dynamic marked import fails at runtime, escapes HTML entities (&, <, >) and converts newlines to <br> tags as a defensive fallback.
Dynamic import

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,
} 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 [content]="message.content" />
      </ng-template>
    </chat-message-list>
  `,
})
export class ChatViewComponent {
  // chatRef = injectAgent(); // with provideAgent({...}) in the component's providers
}

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 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 ViewRegistry via 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).

overrideViews vs withViews

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 } 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 [content]="content" [viewRegistry]="myRegistry" />
  `,
})
export class CustomChatComponent {
  content = '';
  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.

Use 'code-block', not 'code'

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

KeyDescription
'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>)

#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 marked

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