Production-grade, extensible Markdown rendering and editing for React — CommonMark & GFM, math, Mermaid, Shiki highlighting, CodeMirror editor, themes, and i18n in one package.
English · 简体中文 · Repository · npm

- Features
- Quick Start
- API Reference
- ProcessorOptions
- Exported Utilities
- Hooks
- Component Architecture
- Sub-Projects
- Inline HTML
- File & Image Upload
- Customization
- Technology Stack
- Browser Support
- Development
- Contributing
- License
- CommonMark & GFM — Tables, task lists, strikethrough, footnotes, autolinks
- Syntax Highlighting — 30+ languages via Shiki (VS Code–quality themes)
- Math — Inline and block KaTeX (
$...$,$$...$$) - Mermaid — Flowcharts, sequence, Gantt, class diagrams (lazy client render)
- SVG Preview — Fenced
```svgor```xmlwith SVG content renders as a sanitized inline preview (copy / download when not streaming) - Rich Editor — CodeMirror 6, toolbar, split/tabs layouts, image & file paste/drop/upload, auto-save, shortcuts
- Code Block UX — Copy, download (language-based extension; optional
file:/ comment meta for filename), HTML sandbox preview; optional Apply (onApplyCodefor coding agents) - Chat presets — optional
CHAT_PROCESSOR_OPTIONS/createStreamingProcessor(does not change library defaults) - GFM Alerts & Containers —
> [!NOTE]/> [!WARNING]and:::tip/:::warningdirectives - TOC Sidebar — Auto-generated outline with active heading tracking (
MarkdownRenderer) - Front Matter — YAML (and TOML) metadata via remark-frontmatter
- Emoji —
:smile:shortcodes (remark-emoji) - Security — rehype-sanitize schema, URL handling, XSS-oriented defaults
- Accessibility — rehype a11y helpers, ARIA-oriented output
- Streaming —
streamingprop for live SSE/chunked content (short debounce coalesce, skip Shiki/Mermaid while streaming, full highlight on stream end, cursor affordance) - Themes — Light / Dark / Auto mode +
ThemeVariantskin system (Default / Angus / GitHub / Claude); CSS variables throughout - i18n —
en-USandzh-CNbuilt-in - Dual Build — ESM + CJS, TypeScript declarations, subpath entries (
/viewer,/renderer,/editor)
npm install @xcan-cloud/markdownPeer dependencies:
npm install react react-domImport styles once in your app:
import'@xcan-cloud/markdown/styles';This single import includes both the renderer and editor styles. Optional theme presets are imported separately (see Customization).
For viewer / renderer only (no editor chrome), prefer:
import'@xcan-cloud/markdown/styles/renderer';// or editor (+ renderer):import'@xcan-cloud/markdown/styles/editor';
| Entry | Use case | CodeMirror |
|---|---|---|
@xcan-cloud/markdown | Full surface (backward compatible) | In dependency graph |
@xcan-cloud/markdown/viewer | Lightweight preview / SSR | No |
@xcan-cloud/markdown/renderer | Streaming, TOC, Mermaid, code actions | No |
@xcan-cloud/markdown/editor | Rich editing (includes preview) | Yes |
// Chat / read-only — does not pull CodeMirrorimport{MarkdownRenderer,MarkdownProvider}from'@xcan-cloud/markdown/renderer';import'@xcan-cloud/markdown/styles/renderer';import{MarkdownRenderer,CHAT_PROCESSOR_OPTIONS,CHAT_RENDERER_DEFAULTS,typeCodeBlockInfo,}from'@xcan-cloud/markdown/renderer';import'@xcan-cloud/markdown/styles/renderer';functionChatBubble({ md, streaming, onApply }: {md: string;streaming: boolean;onApply?: (info: CodeBlockInfo)=>void;}){return(<MarkdownRenderersource={md}streaming={streaming}options={CHAT_PROCESSOR_OPTIONS}{...CHAT_RENDERER_DEFAULTS}onApplyCode={onApply}/>);}
CHAT_*presets are opt-in; they do not changecreateProcessor/showToclibrary defaults.
import{MarkdownRenderer}from'@xcan-cloud/markdown';import'@xcan-cloud/markdown/styles';functionApp(){return<MarkdownRenderersource="# Hello\n\nThis is **Markdown**."/>;}import{MarkdownEditor}from'@xcan-cloud/markdown';import'@xcan-cloud/markdown/styles';functionApp(){return(<MarkdownEditorinitialValue="# Start editing…"layout="split"onChange={(value)=>console.log(value)}/>);}import{MarkdownProvider,MarkdownEditor,ThemeSwitcher,LocaleSwitcher,}from'@xcan-cloud/markdown';import'@xcan-cloud/markdown/styles';functionApp(){return(// defaultVariant="claude" — Claude skin CSS is bundled in @xcan-cloud/markdown/styles<MarkdownProviderdefaultTheme="auto"defaultVariant="claude"defaultLocale="en-US"><divstyle={{display: 'flex',gap: 8,marginBottom: 16}}><ThemeSwitcher/>{/* switches light / dark / auto */}<LocaleSwitcher/></div><MarkdownEditorinitialValue="# Hello"layout="split"/></MarkdownProvider>);}import{MarkdownViewer}from'@xcan-cloud/markdown';import'@xcan-cloud/markdown/styles';functionPage({ markdown }: {markdown: string}){return<MarkdownViewersource={markdown}theme="light"/>;}Full-featured renderer: TOC, Mermaid/SVG post-processing, code actions, streaming.
| Prop | Type | Default | Description |
|---|---|---|---|
source | string | — | Markdown source |
options | ProcessorOptions | — | Unified pipeline options |
className | string | '' | Root element class |
theme | 'light' | 'dark' | 'auto' | from context / 'auto' | Color mode |
showToc | boolean | true | Show TOC sidebar |
tocPosition | 'left' | 'right' | 'right' | TOC placement |
debounceMs | number | 150 | Render debounce; still applied while streaming, floored at 50ms to coalesce tokens |
onRendered | (info: { html: string; toc: TocItem[] }) => void | — | After successful render |
onLinkClick | (href: string, event: MouseEvent) => void | — | Link click hook |
onImageClick | (src: string, alt: string, event: MouseEvent) => void | — | Image click hook |
components | Partial<Record<string, ComponentType<any>>> | — | Deprecated / no-op: HTML pipeline cannot map React components; value is ignored |
onApplyCode | (info: CodeBlockInfo) => void | — | When set, shows an Apply button on fenced code blocks (injected after streaming ends) |
applyLabel | string | i18n applyCode | Apply button label |
streaming | boolean | false | Live stream: disables Shiki/Mermaid fence transforms for cheap updates; full re-render when streaming ends |
onStreamEnd | () => void | — | Fired when streaming goes true → false |
height | string | — | Fixed height of the renderer container |
minHeight | string | — | Minimum height of the renderer container |
maxHeight | string | — | Maximum height of the renderer container |
Extends renderer props exceptsource is replaced by editor value APIs.
| Prop | Type | Default | Description |
|---|---|---|---|
initialValue | string | '' | Initial markdown |
value | string | — | Controlled value |
onChange | (value: string) => void | — | Content change |
layout | LayoutMode | 'split' | split | tabs | editor-only | preview-only |
layoutModes | LayoutMode[] | ['split', 'tabs', 'editor-only', 'preview-only'] | Controls which layout buttons are shown and the order of layout toolbar cycling |
minHeight / maxHeight | string | — | Editor area sizing |
toolbar | ToolbarConfig | default set | false to hide, or item list |
readOnly | boolean | false | Read-only editor |
onImageUpload | (file: File) => Promise<string> | — | Return URL for pasted/dropped/selected images. See File & Image Upload. |
onImageUploadSettled | (r: { success: true; url: string; file: File } | { success: false; error: unknown; file: File }) => void | — | Fired after each image upload resolves or rejects (for toast / logging) |
onFileUpload | (file: File) => Promise<string> | — | Return URL for pasted/dropped/selected non-image files; inserts a [name](url) link. See File & Image Upload. |
onFileUploadSettled | (r: { success: true; url: string; file: File } | { success: false; error: unknown; file: File }) => void | — | Fired after each file upload resolves or rejects (for toast / logging) |
acceptFileTypes | string | — | accept filter for the toolbar File button's native picker (e.g. .pdf,.zip) |
mixedPastePolicy | 'image-first' | 'text-first' | 'image-and-text' | 'image-first' | Strategy when the clipboard contains both an image and text |
onPaste | (payload: ClipboardPayload, event: ClipboardEvent) => boolean | void | — | Custom paste hook; return true to skip the default flow |
onAutoSave | (value: string) => void | — | Periodic save callback |
autoSaveInterval | number | 30000 | Auto-save interval (ms) |
extensions | Extension[] | [] | Extra CodeMirror extensions |
shortcuts | ShortcutMap | — | Custom keymap handlers |
maxLength | number | — | Hard limit + counter UI |
placeholder | string | i18n default | Editor placeholder text |
All MarkdownRenderer props except source also apply to the preview pane (e.g. options, theme, showToc).
Lightweight viewer using useMarkdown (no CodeMirror).
| Prop | Type | Default | Description |
|---|---|---|---|
source | string | — | Markdown source |
options | ProcessorOptions | — | Pipeline options |
className | string | '' | Root class |
theme | 'light' | 'dark' | 'auto' | from context | Theme |
onRendered | (info: { html: string; toc: TocItem[] }) => void | — | Note: toc is [] in viewer |
height | string | — | Fixed height of the viewer container |
minHeight | string | — | Minimum height of the viewer container |
maxHeight | string | — | Maximum height of the viewer container |
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | App subtree |
defaultTheme | 'light' | 'dark' | 'auto' | 'auto' | Light/dark mode |
defaultVariant | 'default' | 'angus' | 'github' | 'claude' | 'claude' | Visual skin |
defaultLocale | 'en-US' | 'zh-CN' | 'en-US' | Initial locale |
Optional controls; read/write theme and locale via useTheme() / useLocale().
interfaceCodeBlockInfo{code: string;language?: string;filename?: string;}interfaceMarkdownRendererProps{source: string;options?: ProcessorOptions;className?: string;theme?: 'light'|'dark'|'auto';showToc?: boolean;tocPosition?: 'left'|'right';debounceMs?: number;onRendered?: (info: {html: string;toc: TocItem[]})=>void;onLinkClick?: (href: string,event: React.MouseEvent)=>void;onImageClick?: (src: string,alt: string,event: React.MouseEvent)=>void;/** @deprecated ignored — HTML pipeline cannot map React components */components?: Partial<Record<string,React.ComponentType<any>>>;onApplyCode?: (info: CodeBlockInfo)=>void;applyLabel?: string;streaming?: boolean;onStreamEnd?: ()=>void;height?: string;minHeight?: string;maxHeight?: string;}| Option | Type | Default | Description |
|---|---|---|---|
gfm | boolean | true | GitHub Flavored Markdown |
math | boolean | true | KaTeX |
mermaid | boolean | true | Mermaid code blocks (false skips mermaid-container rewrite) |
frontmatter | boolean | true | YAML/TOML front matter |
emoji | boolean | true | Emoji shortcodes |
toc | boolean | false | [[toc]] / [toc] replacement |
sanitize | boolean | true | HTML sanitization |
sanitizeSchema | Schema | internal | Custom rehype-sanitize schema |
codeTheme | string | 'github-dark' | Shiki theme |
highlight | boolean | true | Shiki highlighting (async pipeline) |
allowHtml | boolean | true | Raw HTML path through remark-rehype |
remarkPlugins | Plugin[] | [] | Extra remark plugins |
rehypePlugins | Plugin[] | [] | Extra rehype plugins |
| Export | Description |
|---|---|
createProcessor, renderMarkdown, renderMarkdownSync, parseToAst, resolveProcessorOptionsForRender, processorOptionsCacheKey | Core unified pipeline and render helpers |
CHAT_PROCESSOR_OPTIONS, STREAMING_PROCESSOR_OPTIONS, CHAT_RENDERER_DEFAULTS, createChatProcessor, createStreamingProcessor | Chat / streaming presets (does not change createProcessor defaults) |
ProcessorOptions | Pipeline configuration type |
rehypeHighlightCode | Shiki highlighting rehype plugin |
renderMermaidDiagram, initMermaid | Client Mermaid helpers |
extractToc, remarkToc, TocItem | TOC extraction / remark plugin |
remarkAlert, remarkContainer, remarkCodeMeta | Alert, container, code-meta remark plugins |
parseCodeMeta, extractCodeBlocks, CodeBlockMeta | Fence meta parsing |
sanitizeUrl, processExternalLinks, escapeHtml | Security helpers |
rehypeA11y | Accessibility rehype plugin |
MarkdownWorkerRenderer, RenderCache, sharedRenderCache, splitHtmlBlocks | Worker / cache utilities |
copyToClipboard | Clipboard helper |
slug, resetSlugger | Heading slug utilities |
performImageUpload, performFileUpload, createImageUploadLifecycle, createFileUploadLifecycle, encodeMarkdownUrl, sanitizeAltText, isImageFile, collectImageFiles, generateUploadId | Image & file paste/drop/upload helpers (see File & Image Upload) |
setLocale, getLocale, t, getMessages | i18n API |
ThemeVariant, resolveThemeClass | Skin type and CSS-class resolver |
| Hook | Description |
|---|---|
useMarkdown(source, options?) | Returns { html, toc, isLoading, error, refresh } |
useDebouncedValue(value, delay) | Debounced value |
useScrollSync(editorRef, previewRef) | Bi-directional scroll sync |
┌─────────────────────────────────────────────────────────────┐
│ MarkdownProvider │
│ (theme / locale context) │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ MarkdownEditor│ │MarkdownRenderer│ │ MarkdownViewer │
│ ┌──────────┐ │ │ • unified + │ │ • useMarkdown │
│ │ CodeMirror│ │ │ Shiki/KaTeX │ │ • no CM dep │
│ │ + Toolbar │ │ │ • TOC sidebar │ └──────────────────┘
│ └──────────┘ │ │ • Mermaid/SVG │
│ ┌──────────┐ │ │ • code actions │
│ │ Preview │◄┼──┤ (copy/…) │
│ │ (Renderer)│ │ └────────────────┘
│ └──────────┘ │
└──────────────┘
| Path | Description |
|---|---|
website/ | Vite dev playground / demo app for local development |
src/styles/ | Base markdown-renderer.css and theme presets (themes/github.css, themes/angus.css, themes/claude.css) |
Raw HTML is supported end-to-end (parse → sanitize → render) when the
default allowHtml: true is active. The sanitize schema explicitly
permits class, style, id and data-* on every element, so
sized <img> tags survive the pipeline:
<img src="diagrams/svg/02-lifecycle.svg"
alt="Request lifecycle"
style="max-width:1024px;width:100%;height:auto;" />Security invariants that are still enforced:
<script>,<iframe>,<object>,<embed>are stripped.href/srcwithjavascript:orvbscript:schemes are dropped.data:URLs are only allowed for images.- Unknown tags fall through rehype-sanitize's allowlist.
To further tighten or loosen the policy, pass a custom sanitizeSchema
via ProcessorOptions.
MarkdownEditor supports uploading both images and arbitrary
files (pdf, zip, docx, …) through three entry points: the toolbar
Image / File buttons (native file picker), clipboard paste,
and drag-and-drop. Provide the matching uploader(s) and the editor
handles everything else — inserting a unique placeholder, swapping in
the final Markdown on success, and replacing the placeholder with an
HTML comment on failure.
onImageUploadinserts an image node:.onFileUploadinserts a link:[name](url).
Each callback is independent: wire onImageUpload only, onFileUpload
only, or both. The corresponding toolbar button (and paste/drop routing)
activates only when its callback is provided.
import{MarkdownEditor}from'@xcan-cloud/markdown';asyncfunctionuploadToCdn(file: File): Promise<string>{constfd=newFormData();fd.append('file',file);constres=awaitfetch('/api/upload',{method: 'POST',body: fd});if(!res.ok)thrownewError(`upload failed: ${res.status}`);const{ url }=awaitres.json();returnurl;}<MarkdownEditoronImageUpload={uploadToCdn}// images → onFileUpload={uploadToCdn}// other files → [name](url)acceptFileTypes=".pdf,.zip,.docx"onImageUploadSettled={(r)=>{if(r.success)toast.success(`Uploaded ${r.file.name}`);elsetoast.error(`Upload failed: ${String(r.error)}`);}}onFileUploadSettled={(r)=>{if(r.success)toast.success(`Uploaded ${r.file.name}`);elsetoast.error(`Upload failed: ${String(r.error)}`);}}/>Behavioral guarantees:
- Toolbar pickers. The Image button opens an
image/*picker; the File button opens a picker filtered byacceptFileTypes. Both support multi-select. - Unique placeholders. Each upload gets a random id so concurrent uploads never overwrite each other's insertion point.
- Error resilience. A rejected uploader replaces the placeholder
with
<!-- Upload failed: <reason> -->(not rendered, visible in source). - Multi-file drop. Dropping multiple files uploads them in parallel at the drop point.
- Read-only safe. When
readOnlyis set, paste and drop never write to the document (uploads are skipped). - Localization. Placeholder text uses the active locale
(
editor.uploading,editor.uploadFailed). - URL safety. URLs are percent-encoded for whitespace and
( )so returned CDN URLs containing spaces or parentheses do not break the/[name](url)syntax.
The editor routes text vs. image vs. file pastes separately so typing and rich-text paste are never intercepted unnecessarily:
| Clipboard / drop contents | Default behavior |
|---|---|
| Plain text / HTML only | Browser default (no interception) |
| Image file(s) only | Upload each image via onImageUpload, insert  |
| Image + text (e.g. Windows screenshot) | Controlled by mixedPastePolicy |
| Non-image files only (pdf, zip, …) | Upload each via onFileUpload, insert [name](url) |
| Non-image files + text | Controlled by mixedPastePolicy (same semantics as image + text) |
When the relevant callback is not provided, that payload falls through to the browser default (e.g. without
onFileUpload, pasting a PDF is not intercepted).
<MarkdownEditoronImageUpload={uploadToCdn}mixedPastePolicy="image-and-text"// upload screenshot AND keep caption textonPaste={(payload)=>{if(payload.otherFiles.some(f=>f.type==='application/pdf')){toast.warn('PDF paste ignored');returntrue;// handled — skip default flow}}}/>The classifyClipboard(transfer) helper that powers this (returns
{ images, otherFiles, text, html, uriList, hasImages, hasText, ... })
is exported for custom integrations, alongside performImageUpload,
performFileUpload, createImageUploadLifecycle,
createFileUploadLifecycle, encodeMarkdownUrl, isImageFile,
collectImageFiles.
The theme system has two orthogonal dimensions:
defaultTheme— brightness mode:'light','dark','auto'(followsprefers-color-scheme)defaultVariant— visual skin:'default','angus','github','claude'
The combination maps to a single CSS class on the root container:
| variant \ mode | light | dark |
|---|---|---|
default | markdown-theme-light | markdown-theme-dark |
angus | markdown-theme-angus | markdown-theme-angus-dark |
github | markdown-theme-github | markdown-theme-github-dark |
claude | markdown-theme-claude | markdown-theme-claude-dark |
The Claude theme supports inheriting the brand color from the host application. If the host defines --primary on :root, then --md-accent and --md-link automatically inherit that value; otherwise they fall back to Claude’s default brand color.
/* Host application global styles */:root {
--primary:#3b82f6; /* blue brand */
}
/* Markdown links and accent colors automatically become blue */import'@xcan-cloud/markdown/styles';import{MarkdownProvider,MarkdownRenderer}from'@xcan-cloud/markdown';functionApp(){return(<MarkdownProviderdefaultTheme="auto"><MarkdownRenderersource="# Hello"/></MarkdownProvider>);}The Angus skin CSS is bundled inside@xcan-cloud/markdown/styles — no extra import needed.
import'@xcan-cloud/markdown/styles';import{MarkdownProvider,MarkdownEditor,ThemeSwitcher}from'@xcan-cloud/markdown';functionApp(){return(// defaultVariant="angus": light mode → markdown-theme-angus// dark mode → markdown-theme-angus-dark<MarkdownProviderdefaultTheme="auto"defaultVariant="angus"><ThemeSwitcher/><MarkdownEditorinitialValue="# Hello"layout="split"/></MarkdownProvider>);}The GitHub skin requires an additional CSS import:
import'@xcan-cloud/markdown/styles';import'@xcan-cloud/markdown/themes/github.css';// ← extra import requiredimport{MarkdownProvider,MarkdownRenderer}from'@xcan-cloud/markdown';functionApp(){return(// defaultVariant="github": light mode → markdown-theme-github// dark mode → markdown-theme-github-dark<MarkdownProviderdefaultTheme="light"defaultVariant="github"><MarkdownRenderersource="# Hello"/></MarkdownProvider>);}import{useTheme}from'@xcan-cloud/markdown';functionVariantSwitcher(){const{ variant, setVariant }=useTheme();return(<selectvalue={variant}onChange={(e)=>setVariant(e.target.valueasany)}><optionvalue="default">Default</option><optionvalue="angus">Angus</option><optionvalue="github">GitHub</option></select>);}Override tokens on .markdown-renderer (see stylesheet for --md-* variables).
<MarkdownProviderdefaultLocale="zh-CN"><MarkdownEditorinitialValue="# 你好"/></MarkdownProvider>import{setLocale,t}from'@xcan-cloud/markdown';setLocale('zh-CN');<MarkdownEditortoolbar={false}/><MarkdownEditortoolbar={['bold','italic','|','code']}/>In
layout="tabs", whentoolbar={false}, a minimal built-in switcher (Editor / Preview) is still rendered to keep tabs mode operable.
LayoutMode is publicly exported and can be used in app-side TypeScript:
import{MarkdownEditor,typeLayoutMode}from'@xcan-cloud/markdown';layout controls the currently active layout mode:
split: editor and preview shown side by sidetabs: one pane at a time (Editor / Preview), switchable by toolbar preview action or built-in tabs switchereditor-only: editor pane onlypreview-only: preview pane only
layoutModes controls which modes are available in the layout UI and the cycle order of the layout toolbar action.
- Default:
['split', 'tabs', 'editor-only', 'preview-only'] - Empty array falls back to the default list
- If current
layoutis not included inlayoutModes, it falls back tolayoutModes[0]
Examples:
// Restrict to edit/preview full-page switching only<MarkdownEditorlayout="editor-only"layoutModes={['editor-only','preview-only']}/>// Keep split + tabs only<MarkdownEditorlayout="tabs"layoutModes={['tabs','split']}/>// Fixed height<MarkdownRenderersource={md}height="600px"/><MarkdownViewersource={md}height="400px"/>// Min / max height<MarkdownRenderersource={md}minHeight="200px"maxHeight="80vh"/>// Editor CodeMirror pane height<MarkdownEditorminHeight="300px"maxHeight="700px"/>```python filename=hello.py
print("hi")
```Use parseCodeMeta / extractCodeBlocks for external tooling.
| Category | Technologies |
|---|---|
| Framework | React 18+, TypeScript |
| Markdown | unified, remark, rehype, remark-gfm, remark-math, … |
| Highlighting | Shiki |
| Diagrams | Mermaid (client), KaTeX |
| Editor | CodeMirror 6 |
| Icons | lucide-react |
| Build | Vite, vite-plugin-dts |
Modern evergreen browsers (Chrome, Firefox, Safari, Edge — last 2 major versions). Features like fetch streams / Workers follow browser capabilities.
npm install
npm run dev # website demo
npm run build # library dist
npm test
npm run lint # tsc --noEmit- Fork the repository.
- Create a branch:
git checkout -b feat/your-feature. - Commit with clear messages.
- Push and open a Pull Request.
Please ensure npm run lint and npm run build pass before submitting.
MIT © Markdown package contributors