Skip to content

xcancloud/Markdown

Repository files navigation

Markdown

npm version License: MIT React 18+

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


Markdown Editor — toolbar, split preview with syntax highlighting, math, tables, task lists, alerts, and TOC sidebar

Table of Contents

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 ```svg or ```xml with 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 (onApplyCode for coding agents)
  • Chat presets — optional CHAT_PROCESSOR_OPTIONS / createStreamingProcessor (does not change library defaults)
  • GFM Alerts & Containers> [!NOTE] / > [!WARNING] and :::tip / :::warning directives
  • 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
  • Streamingstreaming prop 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 + ThemeVariant skin system (Default / Angus / GitHub / Claude); CSS variables throughout
  • i18nen-US and zh-CN built-in
  • Dual Build — ESM + CJS, TypeScript declarations, subpath entries (/viewer, /renderer, /editor)

Quick Start

Installation

npm install @xcan-cloud/markdown

Peer dependencies:

npm install react react-dom

Import 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';

Subpath entries (smaller bundles)

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 CodeMirror
import { MarkdownRenderer, MarkdownProvider } from '@xcan-cloud/markdown/renderer';
import '@xcan-cloud/markdown/styles/renderer';

Chat bubble (recommended)

import {
  MarkdownRenderer,
  CHAT_PROCESSOR_OPTIONS,
  CHAT_RENDERER_DEFAULTS,
  type CodeBlockInfo,
} from '@xcan-cloud/markdown/renderer';
import '@xcan-cloud/markdown/styles/renderer';

function ChatBubble({ md, streaming, onApply }: {
  md: string;
  streaming: boolean;
  onApply?: (info: CodeBlockInfo) => void;
}) {
  return (
    <MarkdownRenderer
      source={md}
      streaming={streaming}
      options={CHAT_PROCESSOR_OPTIONS}
      {...CHAT_RENDERER_DEFAULTS}
      onApplyCode={onApply}
    />
  );
}

CHAT_* presets are opt-in; they do not change createProcessor / showToc library defaults.

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="claude" — Claude skin CSS is bundled in @xcan-cloud/markdown/styles
    <MarkdownProvider defaultTheme="auto" defaultVariant="claude" 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; 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 truefalse
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/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).

<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' | 'claude' 'claude' 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 CodeBlockInfo {
  code: string;
  language?: string;
  filename?: string;
}

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;
  /** @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;
}

ProcessorOptions

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

Exported Utilities

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

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, themes/claude.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 / src with javascript: or vbscript: 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.

File & Image Upload

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.

  • onImageUpload inserts an image node: ![alt](url).
  • onFileUpload inserts 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';

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}     // images → ![alt](url)
  onFileUpload={uploadToCdn}      // other files → [name](url)
  acceptFileTypes=".pdf,.zip,.docx"
  onImageUploadSettled={(r) => {
    if (r.success) toast.success(`Uploaded ${r.file.name}`);
    else toast.error(`Upload failed: ${String(r.error)}`);
  }}
  onFileUploadSettled={(r) => {
    if (r.success) toast.success(`Uploaded ${r.file.name}`);
    else toast.error(`Upload failed: ${String(r.error)}`);
  }}
/>

Behavioral guarantees:

  • Toolbar pickers. The Image button opens an image/* picker; the File button opens a picker filtered by acceptFileTypes. 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 readOnly is 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 ![alt](url) / [name](url) syntax.

Paste & drop classification

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 ![alt](url)
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).

<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, performFileUpload, createImageUploadLifecycle, createFileUploadLifecycle, encodeMarkdownUrl, isImageFile, collectImageFiles.

Customization

Themes

The theme system has two orthogonal dimensions:

  • defaultTheme — brightness mode: 'light', 'dark', 'auto' (follows prefers-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

Primary Color Inheritance

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 */

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", when toolbar={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 side
  • tabs: one pane at a time (Editor / Preview), switchable by toolbar preview action or built-in tabs switcher
  • editor-only: editor pane only
  • preview-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 layout is not included in layoutModes, it falls back to layoutModes[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 --noEmit

Contributing

  1. Fork the repository.
  2. Create a branch: git checkout -b feat/your-feature.
  3. Commit with clear messages.
  4. Push and open a Pull Request.

Please ensure npm run lint and npm run build pass before submitting.

License

MIT © Markdown package contributors

About

Production-grade, extensible, high-performance Markdown rendering & editing component for React.

Topics

Resources

License

Stars

1 star

Watchers

1 watching

Forks

Packages

 
 
 

Contributors