npm package: @synclineapi/mdx-editor
Description: Framework-agnostic, plugin-based MDX/Markdown editor with toolbar customization and live preview
Latest version: 1.0.2

# @synclineapi/mdx-editor

[![npm version](https://img.shields.io/npm/v/@synclineapi/mdx-editor.svg)](https://www.npmjs.com/package/@synclineapi/mdx-editor)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.4-blue.svg)](https://www.typescriptlang.org/)
[![WCAG AA](https://img.shields.io/badge/Accessibility-WCAG%20AA-green.svg)](https://www.w3.org/TR/WCAG21/)
[![Website](https://img.shields.io/badge/Website-markdown.synclineapi.com-blue)](https://markdown.synclineapi.com/)

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.

**[Live Demo →](https://markdown.synclineapi.com/)**

---

## Installation

```bash
npm install @synclineapi/mdx-editor
```

---

## Quick Start

### Vanilla JavaScript

```html
<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

```tsx
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

```ts
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 via `ctx.registerCompletion()`
- **Semantic syntax highlighting** — every built-in plugin declares a `provideTokens` function mapping its MDX syntax to semantic token classes (`kw`, `cls`, `fn`, `str`, `op`, `typ`, `num`, `cmt`); consumers can extend this with `registerSyntaxHighlighter()`
- **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`, or `data-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. |
| `renderers` | `Record<string, RendererFn>` | — | Custom preview renderer overrides. |
| `locale` | `Partial<EditorLocale>` | — | i18n label overrides. |
---

## Theming

### CSS custom properties

```css
.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

```ts
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):

```ts
// 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.

```ts
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 as `fn`, value as `str`
- **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.

```ts
import { componentTagTokens } from '@synclineapi/mdx-editor';

provideTokens: componentTagTokens(['Accordion', 'AccordionGroup']),
// <Accordion title="…" defaultOpen>  →  Accordion=cls  title=fn  "…"=str  defaultOpen=fn
```

---

## Autocomplete

### `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.

```ts
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:

```ts
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:

```ts
{
  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.

```ts
import type { EditorPlugin } from '@synclineapi/mdx-editor';

const myPlugin: EditorPlugin = {
  name: 'my-custom-block',

  // Toolbar button
  toolbarItems: [{
    id: 'myBlock',
    label: 'My Block',
    icon: '<svg>...</svg>',
    tooltip: 'Insert my block (Ctrl+Shift+M)',
    action: ({ editor }) => {
      editor.insertBlock('<MyBlock>\n  Content\n</MyBlock>');
    },
  }],

  // Autocomplete — same content as the toolbar action, now also reachable
  // by typing the trigger word in the editor
  completions: [
    {
      label: 'myblock',
      kind: 'snip',
      detail: '<MyBlock> component',
      body: '<MyBlock>\n  $1Content\n</MyBlock>',
    },
    // Component name entry (shows in autocomplete as a class suggestion)
    { label: 'MyBlock', kind: 'cls', detail: 'custom block component' },
  ],

  // Preview renderer
  renderers: [{
    name: 'myBlock',
    pattern: /<MyBlock>([\s\S]*?)<\/MyBlock>/g,
    render: (match) => {
      const m = match.match(/<MyBlock>([\s\S]*?)<\/MyBlock>/);
      return `<div class="my-block">${m?.[1] ?? ''}</div>`;
    },
  }],

  // Syntax highlighting — called per-line, highlight component tag
  // names, attribute names, and attribute values automatically.
  // Use the built-in helper for JSX components:
  provideTokens: componentTagTokens(['MyBlock']),
  // Or write a custom provider for any syntax pattern:
  // provideTokens: (line) => {
  //   const segs = [];
  //   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;
  // },

  shortcuts: [
    {
      key: 'Ctrl+Shift+m',
      action: ({ editor }) => editor.insertBlock('<MyBlock>\n  Content\n</MyBlock>'),
      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()`:

```ts
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

```ts
createEditor({
  container: '#editor',
  toolbar: [['bold', 'italic', '|', 'heading', '|', 'link', 'image']],
});
```

### Multi-row toolbar

```ts
createEditor({
  container: '#editor',
  toolbar: [
    ['heading', '|', 'bold', 'italic', 'strikethrough'],
    ['link', 'image', '|', 'table', 'code'],
  ],
});
```

### Nested dropdowns

```ts
createEditor({
  container: '#editor',
  toolbar: [[
    'bold', 'italic', '|',
    {
      type: 'group',
      label: 'Insert',
      display: 'dropdown',
      items: ['table', 'accordion', 'card', 'steps'],
    },
  ]],
});
```

---

## API Reference

```ts
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)

```ts
const editor = createEditor({
  container: '#editor',
  onChange: (markdown) => {
    console.log('Content changed:', markdown);
  },
});
```

### Event emitter

```ts
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.

```bash
npm run build
npm run size
```

---

## Security

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](./SECURITY.md) for the vulnerability disclosure policy.

---

## Accessibility

`@synclineapi/mdx-editor` targets **WCAG 2.1 AA** compliance:

- All toolbar buttons carry `aria-label` and `title` attributes
- The editor textarea has `aria-label="Markdown editor"` and `aria-multiline="true"`
- The preview pane has `role="region"`, `aria-label="Preview"`, and `aria-live="polite"`
- Mode toggle buttons expose `aria-pressed` state
- 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:

```bash
npm install mermaid       # Mermaid diagrams
npm install katex         # Math / formula rendering
npm install highlight.js  # Syntax highlighting in code blocks
```

---

## License

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.