README.md
August 21, 2026 · View on GitHub
Draftly
A modern, extensible markdown editor and previewer for the web.
Installation • Quick Start • Usage • Features • API • License
Overview
Draftly is a powerful, pluggable markdown editor and preview toolkit built on top of CodeMirror 6. It provides a seamless "rich text" editing experience while preserving standard markdown syntax. Draftly also includes a static HTML renderer that produces output visually identical to the editor, making it perfect for blogs, documentation sites, and content management systems.
Why Draftly?
- 🚀 Modern Architecture: Built on CodeMirror 6 with incremental Lezer parsing.
- 🎨 Rich Editing: WYSIWYG-like experience with full markdown control.
- 🔌 Extensible Plugin System: Add custom rendering, keymaps, and syntax.
- 🖼️ Static Preview: Render markdown to semantic HTML with visual parity.
- 🌗 Theming: First-class support for light and dark modes.
- 📦 Modular Exports: Import only what you need (
draftly/editor,draftly/preview,draftly/plugins).
Installation
Install the package via your preferred package manager:
# npm
npm install draftly
# yarn
yarn add draftly
# pnpm
pnpm add draftly
# bun
bun add draftly
Peer Dependencies
Draftly requires the following CodeMirror packages as peer dependencies. Make sure they are installed in your project:
npm install @codemirror/commands @codemirror/lang-markdown @codemirror/language @codemirror/language-data @codemirror/state @codemirror/view
Quick Start
Get up and running in seconds.
import { EditorView } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { draftly } from "draftly";
const view = new EditorView({
state: EditorState.create({
doc: "# Hello, Draftly!",
extensions: [draftly()],
}),
parent: document.getElementById("editor")!,
});
Usage
Draftly is designed for flexibility. Use it as a CodeMirror extension for interactive editing or as a standalone renderer for static previews.
Editor Integration
Here's a complete example using @uiw/react-codemirror:
import CodeMirror from "@uiw/react-codemirror";
import { draftly, ThemeEnum } from "draftly";
import { createAllPlugins } from "draftly/plugins/all";
import { githubDark } from "@uiw/codemirror-theme-github";
function MarkdownEditor() {
return (
<CodeMirror
value="# Welcome to Draftly\n\nStart writing..."
height="500px"
extensions={[
draftly({
theme: ThemeEnum.DARK,
themeStyle: githubDark,
plugins: createAllPlugins(),
lineWrapping: true,
history: true,
indentWithTab: true,
onNodesChange: (nodes) => console.log("AST:", nodes),
}),
]}
/>
);
}
Editor Configuration (DraftlyConfig)
| Option | Type | Default | Description |
|---|---|---|---|
theme | ThemeEnum | ThemeEnum.AUTO | Theme mode: LIGHT, DARK, or AUTO. |
themeStyle | Extension | undefined | CodeMirror theme extension (e.g., githubDark). |
plugins | DraftlyPlugin[] | [] | Plugins to enable for rendering and parsing. |
baseStyles | boolean | true | Load default base styles. |
disableViewPlugin | boolean | false | Disable rich rendering (raw markdown mode). |
defaultKeybindings | boolean | true | Enable default CodeMirror keybindings. |
history | boolean | true | Enable undo/redo history. |
indentWithTab | boolean | true | Use Tab for indentation. |
highlightActiveLine | boolean | true | Highlight the current line (in raw mode). |
lineWrapping | boolean | true | Enable line wrapping. |
onNodesChange | (nodes: DraftlyNode[]) => void | undefined | Callback fired on every document update with parsed AST. |
markdown | MarkdownConfig[] | [] | Additional Lezer markdown parser extensions. |
extensions | Extension[] | [] | Additional CodeMirror extensions. |
keymap | KeyBinding[] | [] | Additional keybindings. |
Static Preview
Render markdown to semantic HTML for server-side rendering, static site generation, or read-only views.
import { preview, generateCSS, ThemeEnum } from "draftly";
import { createAllPlugins } from "draftly/plugins/all";
const plugins = createAllPlugins();
const markdown = `
# Hello World
This is a **bold** statement with some \`inline code\`.
- Item 1
- Item 2
- Item 3
`;
// Generate HTML
const html = preview(markdown, {
theme: ThemeEnum.LIGHT,
plugins,
sanitize: true,
wrapperClass: "prose",
});
// Generate matching CSS
const css = generateCSS({
theme: ThemeEnum.LIGHT,
plugins,
wrapperClass: "prose",
includeBase: true,
});
// Use in your app
function ArticlePreview() {
return (
<>
<style>{css}</style>
<article dangerouslySetInnerHTML={{ __html: html }} />
</>
);
}
Preview Configuration (PreviewConfig)
| Option | Type | Default | Description |
|---|---|---|---|
plugins | DraftlyPlugin[] | [] | Plugins for rendering. |
theme | ThemeEnum | ThemeEnum.AUTO | Theme mode. |
sanitize | boolean | true | Sanitize HTML output. Browser only — see below. |
sanitizer | (html) => string | undefined | Sanitizer to use instead of the bundled DOMPurify. Required for SSR. |
wrapperClass | string | "draftly-preview" | CSS class for the wrapper element. |
wrapperTag | string | "article" | HTML tag for the wrapper element. |
markdown | MarkdownConfig[] | [] | Additional parser extensions. |
Warning
sanitize: true does nothing outside a browser. It is implemented with DOMPurify,
which needs a DOM, so during SSR or static generation the option is a no-op and any HTML
in the markdown is emitted unsanitized. Draftly warns on the console when this
happens, but if you render untrusted markdown on a server you must pass your own
sanitizer:
import DOMPurify from "isomorphic-dompurify";
preview(markdown, {
plugins,
sanitizer: (html) => DOMPurify.sanitize(html),
});
Draftly does not bundle jsdom — it is heavy, and every browser consumer would pay for
it. Sanitizing at the application layer works equally well.
Features
🎯 Rich Text Editing
Draftly's ViewPlugin decorates the editor to hide markdown syntax and render styled content inline. This provides a WYSIWYG-like experience while keeping the source as plain markdown.
- Inline Formatting: Bold, italic, strikethrough, and code are styled in-place.
- Headings: Rendered with proper sizes and weights.
- Lists: Ordered and unordered lists with custom bullets.
- Images: Displayed inline with alt text and captions.
- Links: Clickable with visual distinction.
- Code Blocks: Syntax highlighted with language detection.
🔌 Plugin Architecture
Every feature in Draftly is a plugin. Plugins can provide:
- CodeMirror Extensions: Custom decorations, widgets, and behaviors.
- Markdown Parser Extensions: Extend the Lezer parser for custom syntax.
- Keymaps: Add keyboard shortcuts.
- Themes: Inject custom styles based on the current theme.
- Preview Renderers: Define how elements are rendered to static HTML.
import { DraftlyPlugin } from "draftly/editor";
class MyCustomPlugin extends DraftlyPlugin {
name = "my-custom-plugin";
onRegister(context) {
console.log("Plugin registered!", context.config);
}
getExtensions() {
return [
/* CodeMirror extensions */
];
}
getKeymap() {
return [
/* KeyBinding[] */
];
}
getMarkdownConfig() {
return {
/* MarkdownConfig */
};
}
theme(mode) {
return {
/* Theme spec */
};
}
}
🌲 AST Access
Access the parsed document structure via the onNodesChange callback. Perfect for building:
- Table of Contents
- Document Outlines
- Navigation Breadcrumbs
- Word/Line Counters
type DraftlyNode = {
from: number; // Start position
to: number; // End position
name: string; // Node type (e.g., "Heading", "Paragraph")
children: DraftlyNode[];
isSelected: boolean; // True if cursor is within this node
};
🌗 Theming
Draftly provides seamless theming with automatic light/dark mode support:
- Auto Detection: Follows system preference with
ThemeEnum.AUTO. - Manual Control: Force
ThemeEnum.LIGHTorThemeEnum.DARK. - Custom Themes: Pass any CodeMirror theme via
themeStyle. - Preview Parity: CSS generation ensures preview matches editor styling.
📦 Modular Imports
Import only what you need to minimize bundle size:
// Core package — the editor, the preview renderer, and the light plugins
import { draftly, preview } from "draftly";
// Editor only
import { draftly, DraftlyPlugin } from "draftly/editor";
// Preview only
import { preview, generateCSS } from "draftly/preview";
// Individual plugins
import { HeadingPlugin, ListPlugin } from "draftly/plugins";
The heavy plugins are opt-in
Three plugins carry large third-party dependencies and live behind their own entry points,
so that nothing importing draftly/plugins pays for them:
| Entry point | Plugin | Dependency | Approx. bundled cost |
|---|---|---|---|
draftly/plugins/mermaid | MermaidPlugin | mermaid | 5.3 MB |
draftly/plugins/math | MathPlugin | katex (peer) | 0 — you install it |
draftly/plugins/emoji | EmojiPlugin | node-emoji | 312 KB |
Compose the set you actually want:
import { draftly } from "draftly";
import { createEssentialPlugins } from "draftly/plugins";
import { MathPlugin } from "draftly/plugins/math";
const extensions = draftly({
plugins: [...createEssentialPlugins(), new MathPlugin()],
});
KaTeX is a peer dependency, and its CSS is yours to bring
katex is a peer dependency, marked optional — install it yourself if you use
MathPlugin, and skip it otherwise. It is not bundled into dist/, so you get exactly one
copy of it.
Rendered math is unstyled without KaTeX's stylesheet, and Draftly does not inject it by default. If you have a build step, import it — this is the cheap path, and your bundler handles the fonts:
import "katex/dist/katex.min.css";
If you have no build step — a <script> tag, a CDN, an embedded editor — let the plugin
inject the stylesheet instead:
new MathPlugin({ injectStyles: true });
That injects KaTeX's CSS with all 20 font faces inlined as data: URIs, once per document,
and costs about 360 KB. The fonts are inlined rather than referenced because KaTeX's own
@font-face rules use relative fonts/KaTeX_* paths, which a <style> element resolves
against your page URL rather than the package — so they 404 unless you happen to serve them
from there. The stylesheet sits behind a dynamic import() and ships as its own chunk, so
leaving injectStyles at its default of false costs nothing.
Or take everything from draftly/plugins/all, which pulls all three by design:
import { createAllPlugins } from "draftly/plugins/all";
const extensions = draftly({ plugins: createAllPlugins() });
API Reference
Exports
| Export | Path | Description |
|---|---|---|
draftly | draftly/editor | Main editor extension factory. |
DraftlyPlugin | draftly/editor | Base class for creating plugins. |
ThemeEnum | draftly/editor | Enum for theme modes (AUTO, LIGHT, DARK). |
DraftlyNode | draftly/editor | Type for AST nodes. |
preview | draftly/preview | Function to render markdown to HTML. |
generateCSS | draftly/preview | Function to generate CSS for preview styling. |
createEssentialPlugins() | draftly/plugins | Builds a fresh set of the essential plugins. Call once per editor. |
createAllPlugins() | draftly/plugins/all | Builds a fresh set of every built-in plugin, heavy ones included. Call once per editor. |
MermaidPlugin | draftly/plugins/mermaid | Mermaid diagrams. Opt-in — pulls mermaid. |
MathPlugin | draftly/plugins/math | LaTeX via KaTeX. Opt-in — katex is an optional peer dependency. |
EmojiPlugin | draftly/plugins/emoji | :shortcode: emoji. Opt-in — pulls node-emoji. |
Keyboard Shortcuts
Contributed by the built-in plugins. Mod is Cmd on macOS and Ctrl elsewhere.
| Shortcut | Action |
|---|---|
Mod-B / Mod-I / Mod-Shift-S | Bold / italic / strikethrough |
Mod-, / Mod-. | Subscript / superscript |
Mod-Shift-H | Highlight |
Mod-E / Mod-Shift-E | Inline code / fenced code block |
Mod-K | Link |
Mod-Shift-I | Image |
Mod-Shift-8 / Mod-Shift-7 | Bullet list / ordered list |
Mod-Shift-9 | Task list |
Mod-Enter | Toggle the task(s) on the selected lines |
Mod-Shift-T | Insert table |
Mod-Alt-Down / Mod-Alt-Right | Add table row / column |
Mod-Alt-Backspace / Mod-Alt-Delete | Remove table row / column |
Tab / Shift-Tab (in a table) | Next / previous cell |
Shift-Enter (in a table) | Insert a line break inside a cell |
Mod-Enter is the only way to toggle a task without a mouse: the rendered checkbox is
deliberately not focusable, because focusable children inside a contenteditable surface
interfere with the editor's own focus and selection handling.
Browser Support
Draftly supports all modern browsers:
| Browser | Version |
|---|---|
| Chrome | 88+ |
| Firefox | 78+ |
| Safari | 14+ |
| Edge | 88+ |
Table column alignment in the raw markdown uses Intl.Segmenter (Chrome 87+,
Safari 14.1+, Firefox 125+) to group grapheme clusters. Where it is unavailable Draftly
falls back to per-code-point measurement, which still handles CJK, emoji and combining
marks and only slightly over-estimates emoji built from ZWJ sequences. The support floor
above is unchanged, and the rendered table view is unaffected either way — it is laid out
with CSS, not with padding.
Contributing
Contributions are welcome! Please read our Contributing Guide before submitting a pull request.