Markdown Rendering#
Dify's web frontend renders all markdown content through a layered pipeline: the public Markdown component preprocesses raw text, then delegates to StreamdownWrapper, which drives the Streamdown library with a custom set of remark/rehype plugins and React components.
Entry Points#
| File | Role |
|---|---|
markdown/index.tsx | Public <Markdown> component β applies preprocessing then renders StreamdownWrapper |
markdown/streamdown-wrapper.tsx | Configures Streamdown with plugins, sanitization, and block-component overrides |
markdown-blocks/index.ts | Barrel export for all specialized block components |
markdown/markdown-utils.ts | Preprocessing utilities (LaTeX normalization, <think> tag conversion, URL transform) |
Rendering Pipeline#
Raw content string
β
βΌ
preprocessThinkTag() βββββ <think> β <details data-think=true>
preprocessLaTeX() βββββ \[β¦\] / \(β¦\) β $$β¦$$
β
βΌ
StreamdownWrapper
βββ remarkPlugins: remark-gfm (singleTilde: false), remark-breaks, math
βββ rehypePlugins: raw β [extra] β sanitize (custom schema) β harden
βββ components: (see block components below)
Preprocessing (markdown-utils.ts) runs synchronously via es-toolkit/compat's flow before Streamdown touches the content :
preprocessLaTeXnormalizes\[β¦\],\(β¦\), and bare$β¦$delimiters to$$β¦$$so the math plugin can parse them, while leaving fenced code blocks untouched.preprocessThinkTagconverts<think>β¦</think>into<details data-think=true>with a sentinel string[ENDTHINKFLAG], allowing theThinkBlockcomponent to detect streaming completion.
Plugin stack :
remark-gfmwith{ singleTilde: false }β enables GFM tables, task lists, etc.remark-breaksβ treats single newlines as<br>.@streamdown/mathβ renders LaTeX via KaTeX (configurable single-dollar trigger viaENABLE_SINGLE_DOLLAR_LATEX).
Sanitization (buildRehypePlugins): extends Streamdown's default raw β sanitize β harden pipeline with a custom ALLOWED_TAGS schema for button, form, input, textarea, details, video, audio, source, mark, sub, sup, kbd, variable, and section . GFM table tags (table, thead, tbody, tr, th, td) are covered by Streamdown's default sanitize schema. The ALLOW_INLINE_STYLES config flag adds style to the global attribute allowlist .
URL sanitization (customUrlTransform): permits http:, https:, mailto:, xmpp:, irc:, ircs:, abbr:, fragments, protocol-relative, and relative URLs; all other schemes return undefined (blocked) .
Block Components#
StreamdownWrapper maps HTML elements to custom React components . All components live under web/app/components/base/markdown-blocks/.
| HTML element | Component | Notes |
|---|---|---|
code | CodeBlock | Shiki syntax highlighting + special renderers (see below). Loaded via dynamic() to avoid SSR. |
img | Img / PluginImg | Plugin-context-aware: uses PluginImg when pluginInfo is set |
p | Paragraph / PluginParagraph | Same plugin-context switch as images |
a | Link | Sanitized link rendering |
video | VideoBlock | Renders <video> with src attribute |
audio | AudioBlock | Renders <audio> with src attribute |
button | MarkdownButton | Interactive buttons embedded in LLM responses |
form | MarkdownForm | Forms embedded in LLM responses |
details | ThinkBlock | Collapsible reasoning/thinking block (see below) |
Tables are rendered natively by Streamdown's GFM support. No custom table block component is registered; the default HTML <table> element flow is used, sanitized through the default rehype-sanitize schema.
Custom components can be injected per call-site via the customComponents prop, which is merged (with override) into the default component map .
CodeBlock specializations#
CodeBlock (code-block.tsx) inspects the fenced-code language tag and routes to specialized renderers :
mermaidβ<Flowchart>(dynamically imported Mermaid component)echartsβReactEchartschartsvgβSVGRendererabcβMarkdownMusic(ABC notation)- Everything else β Shiki syntax highlighting with theme-aware coloring (
shiki-highlight.tsx)
ThinkBlock#
ThinkBlock (think-block.tsx) wraps the preprocessed <details data-think=true> element. It detects whether the [ENDTHINKFLAG] sentinel has arrived in children (indicating the </think> close tag has streamed in), and passes completion state + elapsed time to ThinkingDetails for display . When data-think is absent (a plain <details> not from a <think> tag), it renders the standard HTML element unchanged .
Configuration & Extension Points#
| Config flag | Effect |
|---|---|
ENABLE_SINGLE_DOLLAR_LATEX | Enables $β¦$ as inline math delimiter (passed to @streamdown/math) |
ALLOW_INLINE_STYLES | Adds style to the rehype-sanitize global attribute allowlist |
MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS | Additional literal characters allowed in Markdown form field names. By default, field names must start with a letter and contain only letters, marks, numbers, underscores, and hyphens. This setting allows operators to permit additional characters like punctuation. Example: ()!*&οΌοΌοΌοΌοΌοΌγ.;οΌ+=β |
MARKDOWN_FORM_FIELD_NAME_MAX_LENGTH | Controls the maximum character length allowed for Markdown form field names. Field names exceeding this limit will fail validation. Defaults to 128. Environment variable: NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_MAX_LENGTH |
ALLOW_UNSAFE_DATA_SCHEME | Permits data: URIs in customUrlTransform |
Props accepted by <Markdown> :
customComponentsβ merge additional element β component overridescustomDisallowedElementsβ block extra tags beyond the default iframe/head/html/meta/link/style/body setremarkPlugins/rehypePluginsβ inject additional remark/rehype pluginsisAnimatingβ passed through to Streamdown for streaming animation controlmodeβ Streamdown render mode; defaults to'streaming'pluginInfoβ routesimgandpto plugin-aware variants