@xcan-cloud/markdown
v1.6.0
Published
Production-grade, extensible, high-performance Markdown rendering and editing component
Readme
Markdown
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
Table of Contents
- Features
- Quick Start
- API Reference
- ProcessorOptions
- Exported Utilities
- Hooks
- Component Architecture
- Sub-Projects
- Inline HTML
- Image Paste Upload
- Customization
- Technology Stack
- Browser Support
- Development
- Contributing
- License
Features
- 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 paste/drop, auto-save, shortcuts
- Code Block UX — Copy, download (language-based extension; optional
file:/ comment meta for filename), HTML sandbox preview - 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 (debounce bypass, cursor affordance) - Themes — Light / Dark / Auto mode +
ThemeVariantskin system (Default / Angus / GitHub); CSS variables throughout - i18n —
en-USandzh-CNbuilt-in - Dual Build — ESM + CJS, TypeScript declarations, tree-shakeable entry
Quick Start
Installation
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).
Basic Rendering
import { MarkdownRenderer } from '@xcan-cloud/markdown';
import '@xcan-cloud/markdown/styles';
function App() {
return <MarkdownRenderer source="# Hello\n\nThis is **Markdown**." />;
}Editor (split view)
import { MarkdownEditor } from '@xcan-cloud/markdown';
import '@xcan-cloud/markdown/styles';
function App() {
return (
<MarkdownEditor
initialValue="# Start editing…"
layout="split"
onChange={(value) => console.log(value)}
/>
);
}Theme & Locale Provider
import {
MarkdownProvider,
MarkdownEditor,
ThemeSwitcher,
LocaleSwitcher,
} from '@xcan-cloud/markdown';
import '@xcan-cloud/markdown/styles';
function App() {
return (
// defaultVariant="angus" — angus.css is already bundled inside @xcan-cloud/markdown/styles
<MarkdownProvider defaultTheme="auto" defaultVariant="angus" defaultLocale="en-US">
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<ThemeSwitcher /> {/* switches light / dark / auto */}
<LocaleSwitcher />
</div>
<MarkdownEditor initialValue="# Hello" layout="split" />
</MarkdownProvider>
);
}SSR-Friendly Viewer (no CodeMirror)
import { MarkdownViewer } from '@xcan-cloud/markdown';
import '@xcan-cloud/markdown/styles';
function Page({ markdown }: { markdown: string }) {
return <MarkdownViewer source={markdown} theme="light" />;
}API Reference
<MarkdownRenderer />
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 (disabled while streaming) |
| 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>>> | — | Custom HTML tag mapping |
| streaming | boolean | false | Live stream mode |
| 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 |
<MarkdownEditor />
Extends renderer props except source 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 images. See Image Paste Upload. |
| onImageUploadSettled | (r: { success: true; url: string; file: File } \| { success: false; error: unknown; file: File }) => void | — | Fired after each upload resolves or rejects (for toast / logging) |
| 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).
<MarkdownViewer />
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 |
<MarkdownProvider />
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children | ReactNode | — | App subtree |
| defaultTheme | 'light' \| 'dark' \| 'auto' | 'auto' | Light/dark mode |
| defaultVariant | 'default' \| 'angus' \| 'github' | 'angus' | Visual skin |
| defaultLocale | 'en-US' \| 'zh-CN' | 'en-US' | Initial locale |
<ThemeSwitcher /> / <LocaleSwitcher />
Optional controls; read/write theme and locale via useTheme() / useLocale().
TypeScript (core props)
interface MarkdownRendererProps {
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;
components?: Partial<Record<string, React.ComponentType<any>>>;
streaming?: boolean;
onStreamEnd?: () => void;
height?: string;
minHeight?: string;
maxHeight?: string;
}ProcessorOptions
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| gfm | boolean | true | GitHub Flavored Markdown |
| math | boolean | true | KaTeX |
| mermaid | boolean | true | Mermaid code blocks |
| 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 |
Exported Utilities
| Export | Description |
| --- | --- |
| createProcessor, renderMarkdown, renderMarkdownSync, parseToAst | Core unified pipeline |
| 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, splitHtmlBlocks | Worker / cache utilities |
| copyToClipboard | Clipboard helper |
| slug, resetSlugger | Heading slug utilities |
| performImageUpload, createImageUploadLifecycle, encodeMarkdownUrl, sanitizeAltText, isImageFile, collectImageFiles, generateUploadId | Image paste/drop upload helpers (see Image Paste Upload) |
| setLocale, getLocale, t, getMessages | i18n API |
| ThemeVariant, resolveThemeClass | Skin type and CSS-class resolver |
Hooks
| Hook | Description |
| --- | --- |
| useMarkdown(source, options?) | Returns { html, toc, isLoading, error, refresh } |
| useDebouncedValue(value, delay) | Debounced value |
| useScrollSync(editorRef, previewRef) | Bi-directional scroll sync |
Component Architecture
┌─────────────────────────────────────────────────────────────┐
│ 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)│ │ └────────────────┘
│ └──────────┘ │
└──────────────┘Sub-Projects
| 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) |
Inline HTML
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.
Image Paste Upload
MarkdownEditor supports pasting from the clipboard and drag-and-drop
for images. Provide an uploader that returns the final URL and the
editor handles everything else — inserting a unique placeholder,
swapping in the final  on success, and replacing the
placeholder with an HTML comment on failure.
import { MarkdownEditor } from '@xcan-cloud/markdown';
async function uploadToCdn(file: File): Promise<string> {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: fd });
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
const { url } = await res.json();
return url;
}
<MarkdownEditor
onImageUpload={uploadToCdn}
onImageUploadSettled={(r) => {
if (r.success) toast.success(`Uploaded ${r.file.name}`);
else toast.error(`Upload failed: ${String(r.error)}`);
}}
/>Behavioral guarantees:
- Unique placeholders. Each upload gets a random id so concurrent pastes 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 images uploads them in parallel at the drop point.
- 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 thesyntax.
Paste classification
The editor routes text vs. file pastes separately so typing and rich-text paste are never intercepted unnecessarily:
| Clipboard contents | Default behavior |
| --- | --- |
| Plain text / HTML only | Browser default (no interception) |
| Image file(s) only | Upload each image, insert  |
| Image + text (e.g. Windows screenshot) | Controlled by mixedPastePolicy |
| Non-image files only (pdf, zip, …) | Browser default (not uploaded) |
<MarkdownEditor
onImageUpload={uploadToCdn}
mixedPastePolicy="image-and-text" // upload screenshot AND keep caption text
onPaste={(payload) => {
if (payload.otherFiles.some(f => f.type === 'application/pdf')) {
toast.warn('PDF paste ignored');
return true; // 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,
createImageUploadLifecycle, encodeMarkdownUrl, isImageFile,
collectImageFiles.
Customization
Themes
The theme system has two orthogonal dimensions:
defaultTheme— brightness mode:'light','dark','auto'(followsprefers-color-scheme)defaultVariant— visual skin:'default','angus','github'
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 |
Default skin (light / dark toggle)
import '@xcan-cloud/markdown/styles';
import { MarkdownProvider, MarkdownRenderer } from '@xcan-cloud/markdown';
function App() {
return (
<MarkdownProvider defaultTheme="auto">
<MarkdownRenderer source="# Hello" />
</MarkdownProvider>
);
}Angus skin
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';
function App() {
return (
// defaultVariant="angus": light mode → markdown-theme-angus
// dark mode → markdown-theme-angus-dark
<MarkdownProvider defaultTheme="auto" defaultVariant="angus">
<ThemeSwitcher />
<MarkdownEditor initialValue="# Hello" layout="split" />
</MarkdownProvider>
);
}GitHub skin
The GitHub skin requires an additional CSS import:
import '@xcan-cloud/markdown/styles';
import '@xcan-cloud/markdown/themes/github.css'; // ← extra import required
import { MarkdownProvider, MarkdownRenderer } from '@xcan-cloud/markdown';
function App() {
return (
// defaultVariant="github": light mode → markdown-theme-github
// dark mode → markdown-theme-github-dark
<MarkdownProvider defaultTheme="light" defaultVariant="github">
<MarkdownRenderer source="# Hello" />
</MarkdownProvider>
);
}Switching variant at runtime
import { useTheme } from '@xcan-cloud/markdown';
function VariantSwitcher() {
const { variant, setVariant } = useTheme();
return (
<select value={variant} onChange={(e) => setVariant(e.target.value as any)}>
<option value="default">Default</option>
<option value="angus">Angus</option>
<option value="github">GitHub</option>
</select>
);
}CSS variables
Override tokens on .markdown-renderer (see stylesheet for --md-* variables).
i18n
<MarkdownProvider defaultLocale="zh-CN">
<MarkdownEditor initialValue="# 你好" />
</MarkdownProvider>import { setLocale, t } from '@xcan-cloud/markdown';
setLocale('zh-CN');Toolbar
<MarkdownEditor toolbar={false} />
<MarkdownEditor toolbar={['bold', 'italic', '|', 'code']} />In
layout="tabs", whentoolbar={false}, a minimal built-in switcher (Editor / Preview) is still rendered to keep tabs mode operable.
Layout and layoutModes
LayoutMode is publicly exported and can be used in app-side TypeScript:
import { MarkdownEditor, type LayoutMode } 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
<MarkdownEditor
layout="editor-only"
layoutModes={['editor-only', 'preview-only']}
/>
// Keep split + tabs only
<MarkdownEditor
layout="tabs"
layoutModes={['tabs', 'split']}
/>Height
// Fixed height
<MarkdownRenderer source={md} height="600px" />
<MarkdownViewer source={md} height="400px" />
// Min / max height
<MarkdownRenderer source={md} minHeight="200px" maxHeight="80vh" />
// Editor CodeMirror pane height
<MarkdownEditor minHeight="300px" maxHeight="700px" />Code fence meta
```python filename=hello.py
print("hi")
```Use parseCodeMeta / extractCodeBlocks for external tooling.
Technology Stack
| 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 |
Browser Support
Modern evergreen browsers (Chrome, Firefox, Safari, Edge — last 2 major versions). Features like fetch streams / Workers follow browser capabilities.
Development
npm install
npm run dev # website demo
npm run build # library dist
npm test
npm run lint # tsc --noEmitContributing
- 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.
License
MIT © Markdown package contributors
