Skip to content

Writing Plugins

This guide walks through creating a custom Generative DOM plugin from scratch.

Plugin Structure

Every plugin is a factory function that returns an GenerativeDomPlugin object:

ts
import type { GenerativeDomPlugin, Token, RenderContext, BlockMatch, PluginContext } from '@generative-dom/core';

export function myPlugin(): GenerativeDomPlugin {
  return {
    name: 'my-plugin',
    priority: 150,
    render(token: Token, ctx: RenderContext): HTMLElement | Text | null {
      // Required: render a token into a DOM node
      return null;
    },
  };
}

The name must be unique across all registered plugins. The priority determines matching order (lower = matched first).

Step 1: Define the Syntax

Decide what your plugin will match. For example, a callout box:

markdown
!!! note
This is a callout.
!!!

Step 2: Implement matchBlock

The matchBlock method receives the buffer and a position. Return a BlockMatch if your plugin can handle the content at that position, or null to pass:

ts
matchBlock(buffer: string, pos: number): BlockMatch | null {
  const rest = buffer.slice(pos);
  const match = rest.match(/^!!! (\w+)\n([\s\S]*?)\n!!!\n/);
  if (!match) return null;

  return {
    type: 'callout',
    raw: match[0],
    consumed: match[0].length,
    content: match[2],
    meta: { kind: match[1] },
  };
}

Key rules:

  • consumed must exactly match how many characters the core should advance
  • raw is used for AST diffing -- if it has not changed, the DOM node is reused
  • Return null if the content might be incomplete (waiting for more stream input)

Step 3: Implement render

The render method turns a token into a DOM node. Always use the render context's helpers:

ts
render(token: Token, ctx: RenderContext): HTMLElement {
  const container = ctx.createElement('div');
  container.className = `callout callout-${token.meta?.kind ?? 'note'}`;

  const label = ctx.createElement('strong');
  label.textContent = String(token.meta?.kind ?? 'Note');
  container.appendChild(label);

  const body = ctx.createElement('p');
  // Use renderInline for content that may contain bold, italic, etc.
  body.appendChild(ctx.renderInline(token.content ?? ''));
  container.appendChild(body);

  return container;
}

Rules:

  • Always use ctx.createElement (pool-aware) instead of document.createElement
  • Always use ctx.createText for text nodes
  • Never use innerHTML or insertAdjacentHTML
  • Use ctx.renderInline if inner content should support inline formatting

Step 4: Implement cleanup (Optional)

If your plugin creates event listeners or holds references, clean them up when the element is recycled:

ts
cleanup(element: HTMLElement): void {
  // Remove any custom event listeners or references
  element.removeEventListener('click', this.handleClick);
}

Step 5: Implement init and destroy (Optional)

Use init for one-time setup and destroy for plugin-level cleanup:

ts
init(ctx: PluginContext): void {
  // Store context for later use
  this.ctx = ctx;

  // Register a cleanup callback for destroy
  ctx.onDestroy(() => {
    // Clean up intervals, observers, etc.
  });

  // Check for other plugins
  const codePlugin = ctx.getPlugin('markdown-code');
  if (codePlugin) {
    // Adjust behavior based on other plugins
  }
}

destroy(): void {
  // Release plugin-level resources
}

Step 6: Implement matchInline (Optional)

For inline-level plugins, implement matchInline instead of (or in addition to) matchBlock:

ts
matchInline(text: string, pos: number): InlineMatch | null {
  const rest = text.slice(pos);
  const match = rest.match(/^==(.+?)==/);
  if (!match) return null;

  return {
    type: 'highlight-text',
    raw: match[0],
    consumed: match[0].length,
    content: match[1],
  };
}

Complete Example

A plugin that renders ==highlighted text== as <mark>:

ts
import type { GenerativeDomPlugin, Token, RenderContext, InlineMatch } from '@generative-dom/core';

export function markHighlight(): GenerativeDomPlugin {
  return {
    name: 'mark-highlight',
    priority: 210,

    matchInline(text: string, pos: number): InlineMatch | null {
      const rest = text.slice(pos);
      const match = rest.match(/^==(.+?)==/);
      if (!match) return null;
      return {
        type: 'mark-highlight',
        raw: match[0],
        consumed: match[0].length,
        content: match[1],
      };
    },

    render(token: Token, ctx: RenderContext): HTMLElement {
      const mark = ctx.createElement('mark');
      mark.appendChild(ctx.renderInline(token.content ?? ''));
      return mark;
    },
  };
}

Usage:

ts
const md = new GenerativeDom({
  container: el,
  plugins: [markdownBase(), markdownInline(), markHighlight()],
});

md.push('This is ==highlighted== text.');
md.flush();

Tips

  • Choose a priority that makes sense for your syntax. Block-level custom syntax should be in the 0--99 range. Inline extensions should be near 200.
  • Always handle the case where the stream might be incomplete. If you are not sure you have the full syntax, return null and wait for more input.
  • Use meta to pass extra data from match to render without modifying the token structure.
  • Test your plugin with single-character streaming to verify it handles all split points.