agentleFS
lobehublobehub/lobe-editor/.cursor/rules/plugin-development.mdc

lobe-editor / rules

Cursor rule180 starsChanged 12 days agoMITView on GitHub

Comprehensive plugin development, architecture, and refactoring guidelines for LobeHub Editor

We split this plugin-development.mdc into the rules it gives an agent

3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Keep framework-agnostic logic (commands, hotkeys, update listeners) in the registry.

    line 146
  2. Very specific:

    Add export * from './plugins/<name>'.

    line 262
  3. Very specific:

    Read-only rendering: add the node to src/renderer/nodes/index.ts and a renderer in src/renderer/renderers/ registered in …

    line 265

≈ “Common” is an estimate: at least 1 in 5 of the 6,350 Cursor rule files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
plugin-development.mdc
---
description: Comprehensive plugin development, architecture, and refactoring guidelines for LobeHub Editor
globs: src/plugins/**/*,**/plugin/registry.ts,**/plugin/register.ts
---

# Plugin Development Guidelines

Plugins have two layers: a framework-agnostic **kernel plugin** and an optional **React
plugin** that registers it and renders UI. The step-by-step checklist lives in
[AGENTS.md](mdc:AGENTS.md) ("Adding a New Plugin / Component"); this rule shows the patterns.
Good templates: [hr](mdc:src/plugins/hr/) (small decorator node),
[collapsible](mdc:src/plugins/collapsible/) (element node with commands, Markdown and
LiteXML), [toc](mdc:src/plugins/toc/) (service + hooks, no node), [list](mdc:src/plugins/list/)
(registry with hotkeys).

## Folder Layout

```text
src/plugins/<name>/
├── index.ts          # Barrel: commands, plugin, React plugin
├── index.mdx         # Docs page
├── command/index.ts  # createCommand + register…Command(editor)
├── node/             # Custom Lexical nodes + $create… / $is… helpers
├── plugin/
│   ├── index.ts      # Kernel plugin class
│   └── registry.ts   # Optional: commands + hotkeys (some plugins name it register.ts)
├── react/            # React<Name>Plugin.tsx, type.ts, style.ts, index.ts(x)
├── service/          # Optional services
├── utils/            # Optional helpers
├── demos/            # Docs demos
└── __tests__/        # Vitest tests
```

## Kernel Plugin

Extend [KernelPlugin](mdc:src/editor-kernel/plugin.ts) and type the class with
`IEditorPluginConstructor` / `IEditorPlugin` from `@/types`:

```typescript
import type { DecoratorNode, LexicalEditor } from 'lexical';

import { KernelPlugin } from '@/editor-kernel/plugin';
import type { IEditorKernel, IEditorPlugin, IEditorPluginConstructor } from '@/types';

import { registerMyCommand } from '../command';
import { MyNode } from '../node/MyNode';

export interface MyPluginOptions {
  decorator?: (node: MyNode, editor: LexicalEditor) => any;
  enableHotkey?: boolean;
  theme?: string;
}

export const MyPlugin: IEditorPluginConstructor<MyPluginOptions> = class
  extends KernelPlugin
  implements IEditorPlugin<MyPluginOptions>
{
  static pluginName = 'MyPlugin';

  constructor(
    protected kernel: IEditorKernel,
    public config?: MyPluginOptions,
  ) {
    super();
    kernel.registerNodes([MyNode]);
    kernel.registerThemes({ myNode: config?.theme || '' });
    // Decorator nodes only
    this.registerDecorator(kernel, MyNode.getType(), (node: DecoratorNode<any>, editor) =>
      config?.decorator ? config.decorator(node as MyNode, editor) : null,
    );
  }

  onInit(editor: LexicalEditor): void {
    // Everything registered here is cleaned up on destroy
    this.register(registerMyCommand(editor));
  }
};
```

- `static pluginName` is PascalCase and ends with `Plugin` (`HRPlugin`, `ListPlugin`,
  `CodeblockPlugin`); it must be unique.
- Wrap every listener / command registration in `this.register(...)`.
- Make `config` `public` when a registry function needs it.

## Commands

```typescript
import { COMMAND_PRIORITY_EDITOR, createCommand, type LexicalEditor } from 'lexical';

export const INSERT_MY_NODE_COMMAND = createCommand<{ value: string }>('INSERT_MY_NODE_COMMAND');

export function registerMyCommand(editor: LexicalEditor) {
  return editor.registerCommand(
    INSERT_MY_NODE_COMMAND,
    (payload) => {
      editor.update(() => {
        // $create… / $insertNodes(...)
      });
      return true;
    },
    COMMAND_PRIORITY_EDITOR,
  );
}
```

## Registry (Commands + Hotkeys)

Plugins with several commands or hotkeys put them in `plugin/registry.ts` (`code`, `link`,
`link-highlight`, `list`) or `plugin/register.ts` (`common`, `mention`, `virtual-block`) and
return one cleanup with `mergeRegister`:

```typescript
import { mergeRegister } from '@lexical/utils';
import type { LexicalEditor } from 'lexical';

import type { IEditorKernel } from '@/types';
import { HotkeyEnum } from '@/types/hotkey';

export interface MyRegistryOptions {
  enableHotkey?: boolean;
}

export function registerMyCommands(
  editor: LexicalEditor,
  kernel: IEditorKernel,
  options?: MyRegistryOptions,
) {
  const { enableHotkey = true } = options || {};

  return mergeRegister(
    editor.registerCommand(MY_COMMAND, handler, COMMAND_PRIORITY_EDITOR),
    kernel.registerHotkey(HotkeyEnum.BulletList, () => editor.dispatchCommand(MY_COMMAND, undefined), {
      enabled: enableHotkey,
      preventDefault: true,
      stopImmediatePropagation: true,
    }),
  );
}

// plugin/index.ts
onInit(editor: LexicalEditor): void {
  this.register(registerMyCommands(editor, this.kernel, { enableHotkey: this.config?.enableHotkey }));
}
```

Keep framework-agnostic logic (commands, hotkeys, update listeners) in the registry; keep UI
state, DOM positioning and floating UI in React. See
[hotkey-standards](mdc:.cursor/rules/hotkey-standards.mdc).

## Markdown

```typescript
import { IMarkdownShortCutService } from '@/plugins/markdown/service/shortcut';

const markdown = this.kernel.requireService(IMarkdownShortCutService);

markdown?.registerMarkdownShortCut({ regExp: /^---$/, replace: (parentNode) => { /* ... */ }, trigger: 'enter', type: 'element' });
markdown?.registerMarkdownWriter(MyNode.getType(), (ctx, node) => {
  if ($isMyNode(node)) ctx.appendLine('...\n\n');
});
markdown?.registerMarkdownReader('thematicBreak', () =>
  INodeHelper.createElementNode(MyNode.getType(), {}),
);
```

`INodeHelper` comes from `@/editor-kernel/inode/helper`. Reference:
[src/plugins/hr/plugin/index.ts](mdc:src/plugins/hr/plugin/index.ts).

## LiteXML

Register a writer and a reader so AI edits (LiteXML commands) and headless export round-trip
the node:

```typescript
import { ILitexmlService } from '@/plugins/litexml';

const litexml = this.kernel.requireService(ILitexmlService);

litexml?.registerXMLWriter(MyNode.getType(), (node, ctx) =>
  $isMyNode(node) ? ctx.createXmlNode('my-node', {}) : false,
);
litexml?.registerXMLReader('my-node', () => INodeHelper.createElementNode(MyNode.getType(), {}));
```

## Upload

```typescript
import { IUploadService } from '@/plugins/upload';

this.kernel
  .requireService(IUploadService)
  ?.registerUpload(async (file: File, from: string, range: Range | null | undefined) => {
    // Insert a node for the file inside editor.update(...)
    return true; // handled; return false / null to let the next handler try
  });
```

Reference: [src/plugins/file/plugin/index.ts](mdc:src/plugins/file/plugin/index.ts).

## Services

```typescript
import { genServiceId } from '@/editor-kernel';
import type { IServiceID } from '@/types';

export interface IMyService {
  doSomething(value: string): void;
}

export const IMyService: IServiceID<IMyService> = genServiceId<IMyService>('MyService');

// In the plugin constructor
kernel.registerService(IMyService, new MyService(kernel));
```

Consumers call `kernel.requireService(IMyService)`. Reference:
[src/plugins/toc/service/i-toc-service.ts](mdc:src/plugins/toc/service/i-toc-service.ts).

## React Plugin

```tsx
'use client';

import { type FC, useLayoutEffect } from 'react';

import { useLexicalComposerContext } from '@/editor-kernel/react/react-context';
import { MarkdownPlugin } from '@/plugins/markdown/plugin';

import { MyPlugin } from '../plugin';
import MyNodeView from './components/MyNodeView';
import type { ReactMyPluginProps } from './type';

const ReactMyPlugin: FC<ReactMyPluginProps> = ({ className, enableHotkey = true }) => {
  const [editor] = useLexicalComposerContext();

  useLayoutEffect(() => {
    editor.registerPlugin(MarkdownPlugin);
    editor.registerPlugin(MyPlugin, {
      decorator: (node, editor) => <MyNodeView className={className} editor={editor} node={node} />,
      enableHotkey,
      theme: className,
    });
  }, []);

  return null;
};

ReactMyPlugin.displayName = 'ReactMyPlugin';

export default ReactMyPlugin;
```

- Props live in `react/type.ts` with JSDoc; styles in `react/style.ts` via `createStaticStyles`
  from `@lobehub/ui`.
- `react/index.ts(x)`: `export { default as ReactMyPlugin } from './ReactMyPlugin';` and
  `export type * from './type';`.
- Users pass options with `Editor.withProps(ReactMyPlugin, { ... })`.

## Wiring It Up

1. `src/plugins/<name>/index.ts` exports the commands, kernel plugin and React plugin.
2. Add `export * from './plugins/<name>';` to [src/index.ts](mdc:src/index.ts).
3. Server-side support: add the kernel plugin to `DEFAULT_HEADLESS_EDITOR_PLUGINS` in
   [src/headless/index.ts](mdc:src/headless/index.ts).
4. Read-only rendering: add the node to `src/renderer/nodes/index.ts` and a renderer in
   `src/renderer/renderers/` registered in `createDefaultRenderers()`.
5. User-facing strings go in [src/locale/index.ts](mdc:src/locale/index.ts); use
   `useTranslation()` from `@/editor-kernel/react/useTranslation` or `kernel.t(...)`.
6. Logging: `createDebugLogger('plugin', '<name>')` (see
   [debug-logging](mdc:.cursor/rules/debug-logging.mdc)).
7. Docs + demo: `index.mdx` and `demos/index.tsx` (see
   [documentation-standards](mdc:.cursor/rules/documentation-standards.mdc)); add user-visible
   plugins to the playground `src/react/Editor/demos/index.tsx`.
8. Tests: Vitest + jsdom in `__tests__/`; cover commands and Markdown / LiteXML round-trips.

## Checks

```bash
pnpm type-check
pnpm lint
pnpm lint:circular
pnpm vitest run src/plugins/<name>
pnpm docs:build   # when docs or demos changed
```

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.