Documentation

How to use Rune.

Install it, turn features on or off from one config file, and drive it with a small JavaScript API. This guide covers everything from a five-minute setup to writing your own extensions and wiring up real-time collaboration.

Installation #

Rune has zero runtime dependencies and needs no build step.

# npm
npm install @parityfox/rune-editor

# yarn
yarn add @parityfox/rune-editor

# pnpm
pnpm add @parityfox/rune-editor

Import the stylesheet once in your app entry point:

import '@parityfox/rune-editor/styles';
📦
From source: clone the repo and open examples/index.html with any static server — npx serve . -p 4000. Setting up a specific bundler (Vite / webpack / Rollup / esbuild) or framework? The in-repo Installation & Setup guide covers each one.

Quick start #

The same engine, five adapters. Pick your stack.

Vanilla JS (recommended)

import { createFromConfig } from '@parityfox/rune-editor';
import config from './rune.config.js';
import '@parityfox/rune-editor/styles';

const editor = createFromConfig('#app', config, {
  content: '<p>Start writing…</p>',
  onChange(html) { console.log(html); },
});

React

import { RuneEditor } from '@parityfox/rune-editor/react';
import config from './rune.config.js';
import '@parityfox/rune-editor/styles';

export default function App() {
  return (
    <RuneEditor
      extensions={config.extensions}
      content="<p>Hello</p>"
      onChange={(html) => console.log(html)}
    />
  );
}

Vue 3

<script setup>
import { RuneEditor } from '@parityfox/rune-editor/vue';
import config from './rune.config.js';
import '@parityfox/rune-editor/styles';
</script>

<template>
  <RuneEditor
    :extensions="config.extensions"
    content="<p>Hello</p>"
    @change="(html) => console.log(html)"
  />
</template>

Svelte

<script>
  import { rune } from '@parityfox/rune-editor/svelte';
  import config from './rune.config.js';
  import '@parityfox/rune-editor/styles';
</script>

<div use:rune={{ config, content: '<p>Hello</p>', onChange: (html) => console.log(html) }}></div>

Web Component

<link rel="stylesheet" href="node_modules/@parityfox/rune-editor/styles/rune.css">
<script type="module"
  src="node_modules/@parityfox/rune-editor/adapters/web-component/rune-editor.js"></script>

<rune-editor content="<p>Hello world</p>" placeholder="Start writing…"></rune-editor>

<script>
  document.querySelector('rune-editor')
    .addEventListener('change', (e) => console.log(e.detail)); // html string
</script>

Enable / disable features #

Everything is a single boolean in rune.config.js. Set a feature to false (or omit it) and it disappears from the bundle’s surface, the toolbar, the menus, and the keyboard shortcuts — no other file needs touching. A feature is on unless you explicitly set it to false.

// rune.config.js
const config = {

  // ── Block types ────────────────────────────────
  blocks: {
    paragraph:      true,   // <p>
    heading:        true,   // <h1>–<h3>
    bulletList:     true,   // <ul>
    orderedList:    true,   // <ol>
    blockquote:     true,   // <blockquote>
    codeBlock:      true,   // <pre><code>
    horizontalRule: true,   // <hr>
    callout:        true,   // highlighted callout box
    taskList:       true,   // checklist
    videoEmbed:     true,   // YouTube / Vimeo embed
    image:          true,   // <figure><img>
    table:          true,   // <table>
    toggle:         true,   // collapsible section
    columns:        true,   // side-by-side columns
  },

  // ── Inline marks ───────────────────────────────
  marks: {
    bold: true, italic: true, underline: true, strike: true,
    code: true, link: true, superscript: true, subscript: true,
    fontSize: true, fontFamily: true,
    textColor: true, textBackground: true,
    highlight: true,        // marker-pen highlight (<mark>)
    mention:   false,       // @-mention autocomplete (needs editor.fetchMentions)
    hashtag:   false,       // #tag autocomplete
  },

  // ── Formatting ─────────────────────────────────
  formatting: {
    textAlign: true, lineHeight: true,
    indent: true, outdent: true, clearFormat: true,
  },

  // ── Plugins ────────────────────────────────────
  plugins: {
    markdownShortcuts: true,
    inlineMarkdown:    true,   // **bold**, *italic*, `code`, ~~strike~~ as you type
    smartTypography:   true,   // -- → —, (c) → ©, curly quotes, linkify on paste
    findReplace:       true,
    dragReorder:       true,
    formatPainter:     true,
    emoji:             true,   // :shortcode: autocomplete
  },

  // …toolbar, bubbleMenu, slashMenu, editor, history (below)
};

export default config;
💡
Want a minimal editor? Disable what you don’t need:
marks: { bold: true, italic: true, link: true },  // only these three
plugins: { markdownShortcuts: true },             // findReplace/drag/painter off

Editor behaviour & history #

  editor: {
    placeholder: "Write something, or type '/' for commands…",
    spellcheck:  true,
    autofocus:   false,
    readOnly:    false,
    attribution: true,   // small "Made with Rune" credit; false removes it

    // Optional image-upload hook — avoids base64 bloat.
    // Receives a File, must return Promise<string> (the hosted URL).
    // uploadImage: (file) => fetch('/api/upload', { method:'POST', body: form })
    //                          .then(r => r.json()).then(d => d.url),
  },

  history: {
    enabled:  true,
    maxSteps: 100,   // undo stack depth
  },
OptionDefaultWhat it does
placeholder'Write something…'Empty-state text.
spellchecktrueToggles the browser spellchecker.
autofocusfalseFocus the editor on mount.
readOnlyfalseRender content but block editing.
attributiontrueShows a small “Made with Rune” credit linking to parityfox.com. Set false to remove.
uploadImageAsync hook: (file) => Promise<url>. Without it, images embed as base64.

The slash menu #

Type / at the start of an empty line (or after a space) to open the block inserter. Keep typing to filter; the menu lists only enabled blocks.

  • / — move the selection
  • Enter — insert the highlighted block
  • Esc — close the menu

Available commands: /Paragraph, /Bullet List, /Numbered List, /Quote, /Code Block, /Divider, /Callout, /Task List, /Video, /Image, /Table, /Toggle, /Columns. See the full catalogue on the Features page.

Keyboard shortcuts #

macOS shown; on Windows/Linux use Ctrl for and Alt for .

ShortcutAction
⌘BBold
⌘IItalic
⌘UUnderline
⌘⇧SStrikethrough
⌘EInline code
⌘KInsert / edit link
⌘⌥1 / 2 / 3Heading 1 / 2 / 3
⌘⇧8Bullet list
⌘⇧7Numbered list
⌘⇧BBlockquote
⌘⇧CCode block
⌘ZUndo
⌘⇧Z / ⌘YRedo
⌘FFind & Replace
/Slash command menu
TabIndent list item · next table cell · indent in code block

Markdown shortcuts #

With the markdownShortcuts plugin on, type these at the start of a line. They’re block-level conversions, triggered on Space (or Enter for the divider).

TypeThen pressResult
#SpaceHeading 1
##SpaceHeading 2
###SpaceHeading 3
>SpaceBlockquote
- or *SpaceBullet list
1.SpaceNumbered list
```SpaceCode block
---EnterDivider (horizontal rule)
ℹ️
These are block-level shortcuts. Inline wrapping like **bold** or `code` isn’t auto-converted — use ⌘B / ⌘E or the bubble menu instead. Shortcuts are suppressed inside code blocks, list items, callouts and task lists.

Export #

editor.getHtml()       // → sanitized HTML string
editor.getText()       // → plain text
editor.getMarkdown()   // → Markdown string
editor.getJSON()       // → portable JSON document
editor.print()         // → opens a clean print dialog

Markdown-looking text is converted automatically on paste (disable with pasteMarkdown: false).

Render without a DOM

Two helpers are exported standalone, so a stored document can be rendered to HTML on the server (Node) with no browser:

import { markdownToHtml, jsonToHtml } from '@parityfox/rune-editor';

markdownToHtml('# Hello')   // → '<h1>Hello</h1>'
jsonToHtml(editor.getJSON())// → HTML string, no DOM required

Theming & dark mode #

Rune ships no opinions about how it looks. Every colour, radius and type token is a CSS custom property on :root — override what you like.

:root {
  --rune-color-bg:        #ffffff;
  --rune-color-fg:        #1a1a1a;
  --rune-color-muted:     #9b9b9b;
  --rune-color-border:    #e9e9e7;
  --rune-color-accent:    #2383e2;
  --rune-color-surface:   #f7f7f5;
  --rune-color-hover:     #f1f1ef;
  --rune-color-active-bg: #e8f0fc;
  --rune-color-active-fg: #2383e2;

  --rune-font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
  --rune-font-mono:   'JetBrains Mono', ui-monospace, monospace;
  --rune-font-size:   16px;
  --rune-line-height: 1.75;

  --rune-radius:    6px;
  --rune-radius-lg: 10px;
}

Dark mode — add data-theme="dark" to <html> or any ancestor:

document.documentElement.dataset.theme = 'dark';

JavaScript API #

Everything createFromConfig returns is a full Editor instance.

Content

editor.getHtml()             // → HTML string
editor.setHtml('<p>…</p>')   // set content (sanitized)
editor.getText()             // → plain text
editor.getMarkdown()         // → Markdown
editor.setMarkdown(md)       // ← replace content from Markdown
editor.insertMarkdown(md)    // ← insert Markdown at the caret
editor.getJSON()             // → portable JSON document
editor.setJSON(doc)          // ← replace content from JSON
editor.isEmpty()             // → boolean

Commands & chaining

Run any registered command by name, or queue several and run them together. The chain stops if a command returns false.

editor.cmd('toggleBold')
editor.cmd('setTextColor', '#e03e3e')
editor.cmd('setHeading', 2)
editor.cmd('insertBlock', 'callout')

// chainable
editor.chain().toggleBold().toggleItalic().run()

Common commands: toggleBold, toggleItalic, toggleUnderline, toggleStrike, toggleInlineCode, toggleLink / setLink(href,text) / unsetLink(), setHeading(level), toggleBulletList, toggleOrderedList, toggleBlockquote, toggleCodeBlock, insertHorizontalRule, insertCallout(emoji,color), insertTaskList, insertTable(rows,cols), insertImage(src,alt,caption), insertVideo(url), setFontSize, setFontFamily, setTextColor, setTextBackground, setTextAlign, setLineHeight, indentBlock, outdentBlock, clearFormat, undo, redo.

State

editor.focus()
editor.blur()
editor.enable()
editor.disable()
editor.isActive('bold')      // → boolean (mark active / block current)
editor.destroy()

Events #

Subscribe through the editor’s event bus.

editor.events.on('change',          ({ editor, html }) => { … })
editor.events.on('selectionchange', ({ editor })       => { … })
editor.events.on('keydown',         ({ editor, event }) => { … })
editor.events.on('paste',           ({ editor })       => { … })
editor.events.on('slash:open',      ({ editor })       => { … })
editor.events.on('destroy',         ()                  => { … })
EventFires whenPayload
changeContent changes (input, setHtml, undo/redo){ editor, html }
selectionchangeThe selection moves{ editor }
keydownA key is pressed (after built-in shortcuts){ editor, event }
pasteAfter paste is sanitized + inserted{ editor }
slash:openThe slash menu opens{ editor }
destroydestroy() is called

The bus supports on, once, off, and emit.

Writing extensions #

Blocks, marks, formatting and plugins are all plain objects with a name and type. Hand Rune an object — no class hierarchy, no schema DSL. Pass custom extensions via the Editor’s extensions array.

Block extension

export const MyBlock = {
  name: 'myBlock',
  type: 'block',
  tag:  'div',
  match: (el) => el.dataset?.type === 'myBlock',

  commands(editor) {
    return {
      insertMyBlock: () => editor.cmd('insertBlock', 'myBlock'),
    };
  },

  slashItem: {
    icon: '▦', title: 'My Block', description: 'Insert a custom block',
    action: (editor) => editor.cmd('insertMyBlock'),
  },

  toolbarItem: {
    name: 'myBlock', icon: '<svg>…</svg>', title: 'My Block',
    action: 'insertMyBlock', isActive: (editor) => editor.isActive('myBlock'),
  },
};

Mark extension

export const MyMark = {
  name: 'myMark',
  type: 'mark',
  tag:  'span',
  execCommand: 'underline',           // auto-registers toggleMyMark + isActive

  keymap: {
    'Meta+m':    (editor) => editor.cmd('toggleMyMark'),
    'Control+m': (editor) => editor.cmd('toggleMyMark'),
  },

  toolbarItem: {
    name: 'myMark', icon: '<svg>…</svg>', title: 'My Mark',
    action: 'toggleMyMark', isActive: (editor) => editor.isActive('myMark'),
  },
};

Plugin extension

export const MyPlugin = {
  name: 'myPlugin',
  type: 'plugin',

  init(editor) {
    editor.content.addEventListener('keydown', (e) => {
      // attach behaviours, listen to editor.events, etc.
    });
  },

  commands(editor) {
    return { myCommand: () => { /* … */ } };
  },
};
🧩
Recognised keys: name, type (block/mark/formatting/plugin), tag, match(el) (blocks), execCommand + toggleCommand (marks), commands(editor), keymap, toolbarItem, slashItem, and init(editor) (plugins).

Real-time collaboration #

Collaboration is opt-in and lives in collab/. The core editor stays dependency-free; the collab layer’s deps (Yjs, ws, y-websocket, y-indexeddb) are dev/server-only. Bind a Yjs document and presence to an editor instance:

import * as Y from 'yjs';
import { WebSocketProvider }  from '@parityfox/rune-editor/collab/provider.js';
import { bindParagraphSpike } from '@parityfox/rune-editor/collab/paragraph-binding.js';
import { bindPresence }       from '@parityfox/rune-editor/collab/presence.js';

const doc = new Y.Doc();
const provider = new WebSocketProvider('ws://localhost:1234', 'my-doc', doc);

bindParagraphSpike(editor, doc);                          // sync the document
bindPresence(editor, doc, provider.awareness, {           // live cursors
  name: 'Alice', color: '#2563eb',
});

Run the reference sync server with npm run collab-server, then open the two-pane demo at examples/collab. There’s also an in-process memory-hub transport for demos and tests — no server needed.

📚
Full guides live in the repo under docs/:

See it all in one place.

Browse the complete catalogue of blocks, marks, and plugins.

Feature reference