CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-tiptap--core

Headless rich text editor built on ProseMirror with extensible architecture for building custom editors

94

1.00x
Overview
Eval results
Files

rule-systems.mddocs/

Rule Systems

@tiptap/core provides powerful rule systems for transforming input and pasted content. InputRules enable markdown-like shortcuts during typing, while PasteRules transform content when pasted into the editor.

Capabilities

InputRule

InputRules automatically transform text as you type, enabling markdown-like shortcuts and other input transformations.

/**
 * Rule for transforming text input based on patterns
 */
class InputRule {
  /**
   * Create a new input rule
   * @param config - Rule configuration
   */
  constructor(config: {
    /** Pattern to match against input text */
    find: RegExp | ((value: string) => RegExpMatchArray | null);
    
    /** Handler function to process matches */
    handler: (props: InputRuleHandlerProps) => void | null;
  });
  
  /** Pattern used to match input */
  find: RegExp | ((value: string) => RegExpMatchArray | null);
  
  /** Handler function for processing matches */
  handler: (props: InputRuleHandlerProps) => void | null;
}

interface InputRuleHandlerProps {
  /** Current editor state */
  state: EditorState;
  
  /** Range of the matched text */
  range: { from: number; to: number };
  
  /** RegExp match result */
  match: RegExpMatchArray;
  
  /** Access to single commands */
  commands: SingleCommands;
  
  /** Create command chain */
  chain: () => ChainedCommands;
  
  /** Check command executability */
  can: () => CanCommands;
}

/**
 * Create input rules plugin for ProseMirror
 * @param config - Plugin configuration
 * @returns ProseMirror plugin
 */
function inputRulesPlugin(config: {
  editor: Editor;
  rules: InputRule[];
}): Plugin;

Usage Examples:

import { InputRule } from '@tiptap/core';

// Markdown-style heading rule
const headingRule = new InputRule({
  find: /^(#{1,6})\s(.*)$/,
  handler: ({ range, match, commands }) => {
    const level = match[1].length;
    const text = match[2];
    
    commands.deleteRange(range);
    commands.setNode('heading', { level });
    commands.insertContent(text);
  }
});

// Bold text rule
const boldRule = new InputRule({
  find: /\*\*([^*]+)\*\*$/,
  handler: ({ range, match, commands }) => {
    const text = match[1];
    
    commands.deleteRange(range);
    commands.insertContent({
      type: 'text',
      text,
      marks: [{ type: 'bold' }]
    });
  }
});

// Horizontal rule
const hrRule = new InputRule({
  find: /^---$/,
  handler: ({ range, commands }) => {
    commands.deleteRange(range);
    commands.insertContent({ type: 'horizontalRule' });
  }
});

// Code block rule
const codeBlockRule = new InputRule({
  find: /^```([a-zA-Z]*)?\s$/,
  handler: ({ range, match, commands }) => {
    const language = match[1] || null;
    
    commands.deleteRange(range);
    commands.setNode('codeBlock', { language });
  }
});

// Blockquote rule
const blockquoteRule = new InputRule({
  find: /^>\s(.*)$/,
  handler: ({ range, match, commands }) => {
    const text = match[1];
    
    commands.deleteRange(range);
    commands.wrapIn('blockquote');
    commands.insertContent(text);
  }
});

// List item rule
const listItemRule = new InputRule({
  find: /^[*-]\s(.*)$/,
  handler: ({ range, match, commands }) => {
    const text = match[1];
    
    commands.deleteRange(range);
    commands.wrapIn('bulletList');
    commands.insertContent(text);
  }
});

// Using function-based find pattern
const smartQuoteRule = new InputRule({
  find: (value: string) => {
    const match = value.match(/"([^"]+)"$/);
    return match;
  },
  handler: ({ range, match, commands }) => {
    const text = match[1];
    
    commands.deleteRange(range);
    commands.insertContent(`"${text}"`); // Use smart quotes
  }
});

PasteRule

PasteRules transform content when it's pasted into the editor, allowing custom handling of different content types.

/**
 * Rule for transforming pasted content based on patterns
 */
class PasteRule {
  /**
   * Create a new paste rule
   * @param config - Rule configuration
   */
  constructor(config: {
    /** Pattern to match against pasted content */
    find: RegExp | ((value: string) => RegExpMatchArray | null);
    
    /** Handler function to process matches */
    handler: (props: PasteRuleHandlerProps) => void | null;
  });
  
  /** Pattern used to match pasted content */
  find: RegExp | ((value: string) => RegExpMatchArray | null);
  
  /** Handler function for processing matches */
  handler: (props: PasteRuleHandlerProps) => void | null;
}

interface PasteRuleHandlerProps {
  /** Current editor state */
  state: EditorState;
  
  /** Range where content will be pasted */
  range: { from: number; to: number };
  
  /** RegExp match result */
  match: RegExpMatchArray;
  
  /** Access to single commands */
  commands: SingleCommands;
  
  /** Create command chain */
  chain: () => ChainedCommands;
  
  /** Check command executability */
  can: () => CanCommands;
  
  /** The pasted text content */
  pastedText: string;
  
  /** Drop event (if paste was triggered by drag and drop) */
  dropEvent?: DragEvent;
}

/**
 * Create paste rules plugin for ProseMirror
 * @param config - Plugin configuration
 * @returns Array of ProseMirror plugins
 */
function pasteRulesPlugin(config: {
  editor: Editor;
  rules: PasteRule[];
}): Plugin[];

Usage Examples:

import { PasteRule } from '@tiptap/core';

// URL to link conversion
const urlTolinkRule = new PasteRule({
  find: /https?:\/\/[^\s]+/g,
  handler: ({ range, match, commands }) => {
    const url = match[0];
    
    commands.deleteRange(range);
    commands.insertContent({
      type: 'text',
      text: url,
      marks: [{ type: 'link', attrs: { href: url } }]
    });
  }
});

// YouTube URL to embed
const youtubeRule = new PasteRule({
  find: /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)/,
  handler: ({ range, match, commands }) => {
    const videoId = match[1];
    
    commands.deleteRange(range);
    commands.insertContent({
      type: 'youtube',
      attrs: { videoId }
    });
  }
});

// Email to mailto link
const emailRule = new PasteRule({
  find: /[\w.-]+@[\w.-]+\.\w+/g,
  handler: ({ range, match, commands }) => {
    const email = match[0];
    
    commands.deleteRange(range);
    commands.insertContent({
      type: 'text',
      text: email,
      marks: [{ type: 'link', attrs: { href: `mailto:${email}` } }]
    });
  }
});

// GitHub issue/PR references
const githubRefRule = new PasteRule({
  find: /#(\d+)/g,
  handler: ({ range, match, commands }) => {
    const issueNumber = match[1];
    
    commands.deleteRange(range);
    commands.insertContent({
      type: 'githubRef',
      attrs: { 
        number: parseInt(issueNumber),
        type: 'issue'
      }
    });
  }
});

// Code detection and formatting
const codeRule = new PasteRule({
  find: /`([^`]+)`/g,
  handler: ({ range, match, commands }) => {
    const code = match[1];
    
    commands.deleteRange(range);
    commands.insertContent({
      type: 'text',
      text: code,
      marks: [{ type: 'code' }]
    });
  }
});

// Image URL to image node
const imageRule = new PasteRule({
  find: /(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/gi,
  handler: ({ range, match, commands }) => {
    const src = match[0];
    
    commands.deleteRange(range);
    commands.insertContent({
      type: 'image',
      attrs: { src }
    });
  }
});

// Markdown table detection
const tableRule = new PasteRule({
  find: /^\|(.+)\|\s*\n\|[-\s|]+\|\s*\n((?:\|.+\|\s*\n?)*)/m,
  handler: ({ range, match, commands, chain }) => {
    const headerRow = match[1];
    const rows = match[2];
    
    // Parse markdown table and convert to table node
    const headers = headerRow.split('|').map(h => h.trim()).filter(Boolean);
    const tableRows = rows.split('\n').filter(Boolean).map(row => 
      row.split('|').map(cell => cell.trim()).filter(Boolean)
    );
    
    commands.deleteRange(range);
    chain()
      .insertTable({ rows: tableRows.length + 1, cols: headers.length })
      .run();
  }
});

Rule Extension Integration

How to integrate input and paste rules into extensions.

/**
 * Extension methods for adding rules
 */
interface ExtensionConfig {
  /**
   * Add input rules to the extension
   * @returns Array of input rules
   */
  addInputRules?(): InputRule[];
  
  /**
   * Add paste rules to the extension
   * @returns Array of paste rules
   */
  addPasteRules?(): PasteRule[];
}

Usage Examples:

import { Extension, InputRule, PasteRule } from '@tiptap/core';

// Extension with input and paste rules
const MarkdownExtension = Extension.create({
  name: 'markdown',
  
  addInputRules() {
    return [
      // Heading rules
      new InputRule({
        find: /^(#{1,6})\s(.*)$/,
        handler: ({ range, match, commands }) => {
          const level = match[1].length;
          const text = match[2];
          
          commands.deleteRange(range);
          commands.setNode('heading', { level });
          commands.insertContent(text);
        }
      }),
      
      // Bold text rule
      new InputRule({
        find: /\*\*([^*]+)\*\*$/,
        handler: ({ range, match, commands }) => {
          const text = match[1];
          
          commands.deleteRange(range);
          commands.insertContent({
            type: 'text',
            text,
            marks: [{ type: 'bold' }]
          });
        }
      }),
      
      // Italic text rule
      new InputRule({
        find: /\*([^*]+)\*$/,
        handler: ({ range, match, commands }) => {
          const text = match[1];
          
          commands.deleteRange(range);
          commands.insertContent({
            type: 'text',
            text,
            marks: [{ type: 'italic' }]
          });
        }
      })
    ];
  },
  
  addPasteRules() {
    return [
      // Convert URLs to links
      new PasteRule({
        find: /https?:\/\/[^\s]+/g,
        handler: ({ range, match, commands }) => {
          const url = match[0];
          
          commands.deleteRange(range);
          commands.insertContent({
            type: 'text',
            text: url,
            marks: [{ type: 'link', attrs: { href: url } }]
          });
        }
      }),
      
      // Convert email addresses to mailto links
      new PasteRule({
        find: /[\w.-]+@[\w.-]+\.\w+/g,
        handler: ({ range, match, commands }) => {
          const email = match[0];
          
          commands.deleteRange(range);
          commands.insertContent({
            type: 'text',
            text: email,
            marks: [{ type: 'link', attrs: { href: `mailto:${email}` } }]
          });
        }
      })
    ];
  }
});

// Node with specific input rules
const HeadingNode = Node.create({
  name: 'heading',
  group: 'block',
  content: 'inline*',
  
  addAttributes() {
    return {
      level: {
        default: 1,
        rendered: false,
      },
    };
  },
  
  addInputRules() {
    return [
      new InputRule({
        find: /^(#{1,6})\s(.*)$/,
        handler: ({ range, match, commands }) => {
          const level = match[1].length;
          const text = match[2];
          
          commands.deleteRange(range);
          commands.setNode(this.name, { level });
          commands.insertContent(text);
        }
      })
    ];
  }
});

// Mark with input and paste rules
const LinkMark = Mark.create({
  name: 'link',
  
  addAttributes() {
    return {
      href: {
        default: null,
      },
    };
  },
  
  addInputRules() {
    return [
      // Markdown link syntax: [text](url)
      new InputRule({
        find: /\[([^\]]+)\]\(([^)]+)\)$/,
        handler: ({ range, match, commands }) => {
          const text = match[1];
          const href = match[2];
          
          commands.deleteRange(range);
          commands.insertContent({
            type: 'text',
            text,
            marks: [{ type: this.name, attrs: { href } }]
          });
        }
      })
    ];
  },
  
  addPasteRules() {
    return [
      // Auto-link URLs
      new PasteRule({
        find: /https?:\/\/[^\s]+/g,
        handler: ({ range, match, commands }) => {
          const url = match[0];
          
          commands.deleteRange(range);
          commands.insertContent({
            type: 'text',
            text: url,
            marks: [{ type: this.name, attrs: { href: url } }]
          });
        }
      })
    ];
  }
});

Advanced Rule Patterns

Complex rule patterns and techniques for advanced transformations.

// Advanced input rule patterns

// Multi-line rule detection
const codeBlockRule = new InputRule({
  find: /^```(\w+)?\s*\n([\s\S]*?)```$/,
  handler: ({ range, match, commands }) => {
    const language = match[1];
    const code = match[2];
    
    commands.deleteRange(range);
    commands.insertContent({
      type: 'codeBlock',
      attrs: { language },
      content: [{ type: 'text', text: code }]
    });
  }
});

// Context-aware rules
const smartListRule = new InputRule({
  find: /^(\d+)\.\s(.*)$/,
  handler: ({ range, match, commands, state }) => {
    const number = parseInt(match[1]);
    const text = match[2];
    
    // Check if we're already in a list
    const isInList = state.selection.$from.node(-2)?.type.name === 'orderedList';
    
    commands.deleteRange(range);
    
    if (isInList) {
      commands.splitListItem('listItem');
    } else {
      commands.wrapIn('orderedList', { start: number });
    }
    
    commands.insertContent(text);
  }
});

// Conditional rule application
const conditionalRule = new InputRule({
  find: /^@(\w+)\s(.*)$/,
  handler: ({ range, match, commands, state, can }) => {
    const mentionType = match[1];
    const text = match[2];
    
    // Only apply if we can insert mentions
    if (!can().insertMention) {
      return null; // Don't handle this rule
    }
    
    commands.deleteRange(range);
    commands.insertMention({ type: mentionType, label: text });
  }
});

// Rule with side effects
const trackingRule = new InputRule({
  find: /^\$track\s(.*)$/,
  handler: ({ range, match, commands }) => {
    const eventName = match[1];
    
    // Track the event
    analytics?.track(eventName);
    
    commands.deleteRange(range);
    commands.insertContent(`Tracked: ${eventName}`);
  }
});

Rule Debugging and Testing

Utilities for debugging and testing rule behavior.

// Debug rule matching
function debugInputRule(rule: InputRule, text: string): boolean {
  if (typeof rule.find === 'function') {
    const result = rule.find(text);
    console.log('Function match result:', result);
    return !!result;
  } else {
    const match = text.match(rule.find);
    console.log('RegExp match result:', match);
    return !!match;
  }
}

// Test rule handler
function testRuleHandler(
  rule: InputRule, 
  text: string, 
  mockCommands: Partial<SingleCommands>
): void {
  const match = typeof rule.find === 'function' 
    ? rule.find(text) 
    : text.match(rule.find);
    
  if (match) {
    rule.handler({
      state: mockState,
      range: { from: 0, to: text.length },
      match,
      commands: mockCommands as SingleCommands,
      chain: () => ({} as ChainedCommands),
      can: () => ({} as CanCommands)
    });
  }
}

// Rule performance testing
function benchmarkRule(rule: InputRule, testCases: string[]): number {
  const start = performance.now();
  
  testCases.forEach(text => {
    if (typeof rule.find === 'function') {
      rule.find(text);
    } else {
      text.match(rule.find);
    }
  });
  
  return performance.now() - start;
}

Usage Examples:

// Debug heading rule
const headingRule = new InputRule({
  find: /^(#{1,6})\s(.*)$/,
  handler: ({ range, match, commands }) => {
    console.log('Heading match:', match);
    // ... handler logic
  }
});

debugInputRule(headingRule, '## My Heading'); // true
debugInputRule(headingRule, 'Regular text'); // false

// Test rule performance
const testCases = [
  '# Heading 1',
  '## Heading 2', 
  'Regular paragraph',
  '**Bold text**',
  'More normal text'
];

const time = benchmarkRule(headingRule, testCases);
console.log(`Rule processed ${testCases.length} cases in ${time}ms`);

// Test rule in isolation
testRuleHandler(headingRule, '## Test Heading', {
  deleteRange: (range) => console.log('Delete range:', range),
  setNode: (type, attrs) => console.log('Set node:', type, attrs),
  insertContent: (content) => console.log('Insert content:', content)
});

Install with Tessl CLI

npx tessl i tessl/npm-tiptap--core

docs

command-system.md

document-helpers.md

editor-core.md

extension-system.md

index.md

rule-systems.md

utilities.md

tile.json