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 PartialMatchSignal ({ status: 'partial', minCharsNeeded?: number }) when you detect a promising prefix but need more input to confirm a full match. This prevents lower-priority plugins (like markdown-base) from consuming the partial content as paragraph text.
  • Return null only when the content definitively does not match your pattern.

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.
  • Streaming safety — HTML-like tags: If your plugin matches content-bearing HTML-like elements (<tag>...</tag>), never return null when only the opening tag is present and the closing tag hasn't arrived yet. Instead, return PartialMatchSignal to reserve the position. See custom-elements plugin for the reference pattern.
  • Always handle the case where the stream might be incomplete. If you are not sure you have the full syntax, return PartialMatchSignal to hold the position while waiting for more data.
  • 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.