@synclineapi/mdx-editor
v2.0.1
Published
Framework-agnostic, plugin-based MDX/Markdown editor with toolbar customization and live preview
Maintainers
Readme
@synclineapi/mdx-editor
A framework-agnostic, plugin-based MDX/Markdown editor with a high-performance virtual-rendering code pane, live HTML preview, a configurable toolbar, and 38 built-in content plugins. Works with React, Vue, Angular, Next.js, Svelte, or plain JavaScript.
Installation
npm install @synclineapi/mdx-editorQuick Start
Vanilla JavaScript
<div id="editor" style="height: 600px;"></div>
<script type="module">
import { createEditor } from '@synclineapi/mdx-editor';
import '@synclineapi/mdx-editor/style.css';
const editor = createEditor({
container: '#editor',
value: '# Hello World\n\nStart writing *MDX* here.',
onChange: (markdown) => console.log(markdown),
});
</script>React
import { useEffect, useRef } from 'react';
import { createEditor, type SynclineMDXEditor } from '@synclineapi/mdx-editor';
import '@synclineapi/mdx-editor/style.css';
export function Editor({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const ref = useRef<HTMLDivElement>(null);
const editor = useRef<SynclineMDXEditor | null>(null);
useEffect(() => {
if (!ref.current) return;
editor.current = createEditor({ container: ref.current, value, onChange });
return () => editor.current?.destroy();
}, []);
return <div ref={ref} style={{ height: 600 }} />;
}Custom Plugin Selection
import { SynclineMDXEditor, boldPlugin, italicPlugin, headingPlugin } from '@synclineapi/mdx-editor';
import '@synclineapi/mdx-editor/style.css';
const editor = new SynclineMDXEditor({
container: '#editor',
plugins: [headingPlugin(), boldPlugin(), italicPlugin()],
toolbar: [['heading', '|', 'bold', 'italic']],
});Features
38 Built-in Plugins
| Category | Plugins | |----------|---------| | Formatting | Heading (H1–H6), Bold, Italic, Strikethrough, Quote | | Links & Media | Link, Image, Image Background, Image Frame | | Code | Inline Code, Fenced Code Block | | Lists | Unordered List, Ordered List, Task List | | Layout | Table, Multi-Column (2–5 cols), Tabs, Container | | Components | Accordion, Accordion Group, Card, Card Group, Steps | | Callouts | Admonition (Tip / Warning / Caution / Danger / Info / Note), Tip (Good / Bad / Info) | | Rich Content | Highlight (8 colours), Emoji, Formula (KaTeX inline & block), Tooltip, Copy Text | | Inline labels | Badge (success / warning / error / info), Status Tag (live / beta / soon / archived) | | Embeds | YouTube, Video file, GitHub Gist, Twitter/X, CodePen, CodeSandbox | | Diagrams | Mermaid (Flowchart, Sequence, Class, State, ER, Journey, Gantt) | | Insert | API Endpoint, Markdown Import, Code Snippet |
Editor Engine
- Virtual rendering — only visible rows are in the DOM; handles documents of 10 000+ lines at 60 fps
- Word wrap — proper pixel-width wrap with full active-line highlight across all visual rows
- MDX autocomplete — context-aware component-tag and attribute suggestions; Tab-trigger snippet expansion; plugins contribute items declaratively via
completions: [...]or dynamically viactx.registerCompletion() - Semantic syntax highlighting — every built-in plugin declares a
provideTokensfunction mapping its MDX syntax to semantic token classes (kw,cls,fn,str,op,typ,num,cmt); consumers can extend this withregisterSyntaxHighlighter() - Live HTML preview — side-by-side split / editor-only / preview-only modes with a draggable splitter
- Table of Contents — auto-generated from headings, collapsible panel
- Theme system — light / dark toggle; auto-syncs with
data-theme,.smdx-dark, ordata-color-scheme
Configuration
EditorConfig
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| container | HTMLElement \| string | — | Required. DOM element or CSS selector to mount into. |
| value | string | '' | Initial markdown content. |
| onChange | (value: string) => void | — | Called on every content change (typing, paste, undo, setValue()). |
| plugins | EditorPlugin[] | all built-ins | Plugins to register. |
| toolbar | ToolbarConfig | default toolbar | Toolbar layout (rows of item IDs, groups, dividers). |
| theme | Record<string, string> | — | CSS custom property overrides for the MDX prose shell. |
| mode | 'split' \| 'editor' \| 'preview' | 'split' | Initial layout mode. |
| placeholder | string | — | Placeholder shown in an empty editor. |
| readOnly | boolean | false | Prevent all content mutations. |
| locale | Partial<EditorLocale> | — | i18n label overrides. |
Theming
CSS custom properties
.smdx-editor {
--smdx-primary: #6366f1; /* accent colour */
--smdx-bg: #ffffff; /* prose shell background */
--smdx-text: #1e293b; /* prose text */
--smdx-border: #e2e8f0; /* dividers */
--smdx-font-family: 'Inter', sans-serif;
--smdx-font-mono: 'JetBrains Mono', monospace;
--smdx-radius: 12px;
}Via config
createEditor({
container: '#editor',
theme: {
'--smdx-primary': '#e11d48',
'--smdx-bg': '#0f172a',
},
});Dark mode
Add .smdx-dark to the editor root, any ancestor element, <body>, or <html> — the editor observes all of them. Alternatively set data-theme="dark" on any ancestor (Next.js / Nuxt / Tailwind pattern):
// CSS class toggle
editor.getRoot().classList.add('smdx-dark');
// data-theme attribute (works on any ancestor)
document.documentElement.dataset.theme = 'dark';Syntax Highlighting
registerSyntaxHighlighter()
Add custom token providers that colour your own MDX component syntax on top of the built-in highlighting. Every built-in plugin already declares a provideTokens function; this API lets you extend it from outside.
import type { PluginTokenProvider } from '@synclineapi/mdx-editor';
import { componentTagTokens } from '@synclineapi/mdx-editor';
// Quickest option — use the built-in JSX helper
// Highlights <MyCard …> tag names as `cls`, attribute names as `fn`, values as `str`.
// Handles boolean attributes like `defaultOpen` and `disabled` automatically.
editor.registerSyntaxHighlighter(componentTagTokens(['MyCard', 'MyCardGroup']));
// Custom provider for non-JSX syntax
const myProvider: PluginTokenProvider = (line) => {
const segs = [];
// Highlight :::type admonition-style fences
const m = line.match(/^(:::)(\w+)/);
if (m) {
segs.push({ cls: 'kw', start: 0, end: 3 });
segs.push({ cls: 'cls', start: 3, end: 3 + m[2].length });
}
return segs;
};
editor.registerSyntaxHighlighter(myProvider);
// Pass an array to register several at once
editor.registerSyntaxHighlighter([providerA, providerB]);Token classes
| cls | Colour role | Typical MDX use |
|-------|-------------|-----------------|
| kw | purple | Heading #, code fences ```, :::, ---, blockquotes > |
| str | green | Link URLs, attribute values, inline `code`, $math$ |
| cmt | muted gray | ~~Strikethrough~~ text |
| fn | blue | Link labels, italic, attribute names |
| num | orange | Bold text, $$formula$$ markers |
| cls | yellow | <Card>, <Tabs>, <Accordion> — JSX component tag names |
| op | cyan | List markers - 1., table pipes \| |
| typ | blue | Code-fence language identifiers (mermaid, ts, python) |
| dec | red | Decorators (reserved for custom use) |
componentTagTokens() helper
The built-in JSX highlighter factory. Handles:
- Tag names:
<Card,</Card>,<Card />→cls - Value attributes:
title="…",src="…"→ name asfn, value asstr - Boolean attributes:
defaultOpen,disabled,open(no=) →fn - Multi-line tags: attributes that continue on the next line(s) are highlighted correctly — the provider tracks open-tag state across lines.
- Attribute scanning is scoped to the region between the tag name and
>so tag body text is never falsely coloured.
import { componentTagTokens } from '@synclineapi/mdx-editor';
provideTokens: componentTagTokens(['Accordion', 'AccordionGroup']),
// <Accordion title="…" defaultOpen> → Accordion=cls title=fn "…"=str defaultOpen=fnAutocomplete
registerAutoComplete()
Add custom autocomplete items on top of the built-in MDX completions. Items appear in the popup immediately and persist for the lifetime of the editor.
import type { CompletionItem } from '@synclineapi/mdx-editor';
editor.registerAutoComplete([
// Plain word completion
{ label: 'MyCard', kind: 'cls', detail: 'component' },
// Snippet — Tab or click inserts the full body; $1 is the cursor position
{
label: 'mycard',
kind: 'snip',
detail: '<MyCard> component',
description: 'Inserts a custom card block.',
body: '<MyCard title="$1">\n $2\n</MyCard>',
},
]);Call it again at any time to add more items — they accumulate:
editor.registerAutoComplete({ label: 'MyBanner', kind: 'cls', detail: 'component' });Completion kinds
| kind | Badge | Use for |
|--------|-------|---------|
| 'cls' | C | Component names (<MyCard>, <Tabs>) |
| 'fn' | f | Functions / methods |
| 'kw' | K | Keywords |
| 'var' | · | Attributes, variables, props |
| 'typ' | T | Types / interfaces |
| 'snip' | S | Snippet template — set body to expand on accept |
Snippet bodies
Use $1, $2, … as cursor tab stops. The cursor lands on $1 after expansion:
{
label: 'endpoint',
kind: 'snip',
detail: 'API endpoint block',
body: '<Endpoint method="$1GET" path="$2/api/resource">\n $3Description\n</Endpoint>',
}Plugin API
Plugins are the recommended way to bundle a toolbar button, keyboard shortcut, preview renderer, and autocomplete items together as one reusable unit.
components — declarative JSX-style components (recommended)
Use defineComponent() to create fully typed component renderers. TypeScript infers the exact prop types from your attrs declaration — no regex, no string parsing, no manual casts.
import { defineComponent } from '@synclineapi/mdx-editor';
import type { EditorPlugin } from '@synclineapi/mdx-editor';
export function badgePlugin(): EditorPlugin {
return {
name: 'badge',
toolbarItems: [{
id: 'badge',
label: 'Badge',
icon: '<svg>...</svg>',
tooltip: 'Insert badge',
action: ({ editor }) => {
editor.insertBlock('<Badge type="info" label="New" />');
},
}],
// ✅ Declarative, typed, zero regex
components: [
defineComponent({
tag: 'Badge',
attrs: {
type: { type: 'enum', options: ['success', 'warning', 'error', 'info'], default: 'info' },
label: { type: 'string', default: 'Badge' },
},
render: ({ type, label }) =>
// ^ 'success' | 'warning' | 'error' | 'info' (fully inferred!)
// ^ string
`<span class="badge badge-${type}">${label}</span>`,
}),
],
styles: `.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: 600; }`,
};
}attrs type mapping:
| Declaration | render receives |
|---|---|
| { type: 'enum', options: ['a','b','c'] } | 'a' \| 'b' \| 'c' |
| { type: 'number', default: 2 } | number |
| { type: 'boolean', default: false } | boolean |
| { type: 'string' } | string |
| (block component) | + children?: string |
Tokens and autocomplete completions are registered automatically for every tag listed in components — no provideTokens or completions arrays needed.
patterns — raw regex for non-JSX fenced syntax
Use patterns only when you need to match syntax that cannot be expressed as a JSX component — fenced code blocks, :::type admonition fences, $...$ math delimiters, and similar:
import type { EditorPlugin } from '@synclineapi/mdx-editor';
const myPlugin: EditorPlugin = {
name: 'my-custom-block',
toolbarItems: [{
id: 'myBlock',
label: 'My Block',
icon: '<svg>...</svg>',
tooltip: 'Insert my block (Ctrl+Shift+M)',
action: ({ editor }) => {
editor.insertBlock(':::myblock\nContent\n:::');
},
}],
// ⚠️ Only for non-JSX fenced syntax — use `components` for JSX-style tags
patterns: [{
name: 'myBlock',
pattern: /:::myblock\n([\s\S]*?):::/g,
render: (match) => {
const m = match.match(/:::myblock\n([\s\S]*?):::/);
return `<div class="my-block">${m?.[1] ?? ''}</div>`;
},
priority: 5,
// Co-locate syntax token colouring with the pattern — fully self-contained
provideTokens: (line) => {
const segs = [];
const m = line.match(/^(:::)(myblock)/);
if (m) {
segs.push({ cls: 'kw', start: 0, end: 3 });
segs.push({ cls: 'cls', start: 3, end: 3 + m[2].length });
} else if (line === ':::') {
segs.push({ cls: 'kw', start: 0, end: 3 });
}
return segs;
},
}],
shortcuts: [
{
key: 'Ctrl+Shift+m',
action: ({ editor }) => editor.insertBlock(':::myblock\nContent\n:::'),
description: 'Insert custom block',
},
],
styles: `.my-block { border: 2px solid #6366f1; padding: 16px; border-radius: 8px; }`,
};You can also register completions dynamically inside init() using ctx.registerCompletion():
const myPlugin: EditorPlugin = {
name: 'dynamic-completions',
async init(ctx) {
const components = await fetchMyComponents(); // dynamic list
for (const comp of components) {
ctx.registerCompletion({
label: comp.tag,
kind: 'cls',
detail: comp.description,
});
}
},
};Completion merge order
Every time a plugin is registered or unregistered the autocomplete list is rebuilt:
built-in MDX completions (tags, JSX attributes)
+ built-in MDX snippets (toolbar-matched snippet bodies)
+ plugin completions (all registered plugins, in order)
+ user completions (registerAutoComplete() calls)Toolbar Customization
Flat toolbar
createEditor({
container: '#editor',
toolbar: [['bold', 'italic', '|', 'heading', '|', 'link', 'image']],
});Multi-row toolbar
createEditor({
container: '#editor',
toolbar: [
['heading', '|', 'bold', 'italic', 'strikethrough'],
['link', 'image', '|', 'table', 'code'],
],
});Nested dropdowns
createEditor({
container: '#editor',
toolbar: [[
'bold', 'italic', '|',
{
type: 'group',
label: 'Insert',
display: 'dropdown',
items: ['table', 'accordion', 'card', 'steps'],
},
]],
});API Reference
interface SynclineMDXEditor {
// Content
getValue(): string;
setValue(value: string): void;
// Editing
insertText(text: string): void;
insertBlock(template: string): void;
wrapSelection(prefix: string, suffix: string): void;
replaceSelection(text: string): void;
replaceCurrentLine(text: string): void;
insertAt(position: number, text: string): void;
// Selection & cursor
getSelection(): SelectionState;
setSelection(start: number, end: number): void;
getCurrentLine(): string;
getCurrentLineNumber(): number;
jumpToLine(lineNumber: number): void;
// Undo / Redo
undo(): void;
redo(): void;
// View
focus(): void;
renderPreview(): void;
getMode(): 'split' | 'editor' | 'preview';
setMode(mode: 'split' | 'editor' | 'preview'): void;
setLineNumbers(enabled: boolean): void;
// Metrics
getWordCount(): number;
getLineCount(): number;
// DOM access
getRoot(): HTMLElement;
getTextarea(): HTMLTextAreaElement;
getPreview(): HTMLElement;
// Plugins
registerPlugin(plugin: EditorPlugin): void;
unregisterPlugin(name: string): void;
// Autocomplete
registerAutoComplete(items: CompletionItem | CompletionItem[]): void;
// Syntax highlighting
registerSyntaxHighlighter(fn: PluginTokenProvider | PluginTokenProvider[]): void;
// Theme sync
syncCodeEditorTheme(): void;
// Events
on(event: string, handler: (data?: unknown) => void): void;
off(event: string, handler: (data?: unknown) => void): void;
emit(event: string, data?: unknown): void;
// Lifecycle
destroy(): void;
}Events
onChange callback (recommended)
const editor = createEditor({
container: '#editor',
onChange: (markdown) => {
console.log('Content changed:', markdown);
},
});Event emitter
editor.on('change', (markdown) => console.log('Changed:', markdown));
editor.on('selection-change', (sel) => console.log('Selection:', sel));
editor.on('mode-change', (mode) => console.log('Mode:', mode));
editor.on('render', (html) => console.log('Preview HTML ready'));
editor.on('focus', () => console.log('Editor focused'));
editor.on('blur', () => console.log('Editor blurred'));
editor.on('toolbar-action', (id) => console.log('Toolbar clicked:', id));Bundle Size
The production build targets < 150 kB (ESM + UMD, gzipped) when mermaid, katex, and highlight.js are treated as external peer dependencies.
npm run build
npm run sizeSecurity
All rendered Markdown/MDX HTML is sanitized by a built-in XSS sanitizer before DOM injection. Dangerous tags (script, iframe, object), event-handler attributes (onclick, onerror, etc.), and javascript: protocol URLs are stripped automatically. See SECURITY.md for the vulnerability disclosure policy.
Accessibility
@synclineapi/mdx-editor targets WCAG 2.1 AA compliance:
- All toolbar buttons carry
aria-labelandtitleattributes - The editor textarea has
aria-label="Markdown editor"andaria-multiline="true" - The preview pane has
role="region",aria-label="Preview", andaria-live="polite" - Mode toggle buttons expose
aria-pressedstate - Toolbar dropdowns use
role="menu"/role="menuitem" - A skip link is provided for keyboard-only users
- All foreground/background colour pairs meet WCAG AA contrast ratios
Peer Dependencies
mermaid, katex, and highlight.js are optional. Install only the ones you need:
npm install mermaid # Mermaid diagrams
npm install katex # Math / formula rendering
npm install highlight.js # Syntax highlighting in code blocksLicense
Copyright © Syncline API. All rights reserved.
This software is proprietary and confidential. Unauthorized copying, distribution, modification, or use of this software, in whole or in part, is strictly prohibited without the express written permission of Syncline API.
