Skip to content

Latest commit

 

History

History
558 lines (429 loc) · 13.2 KB

File metadata and controls

558 lines (429 loc) · 13.2 KB

ZapBB Plugin Authoring Guide

This guide covers how to build and register build-time plugins for ZapBB. Build-time plugins are compiled into the application bundle and are not loaded dynamically at runtime.


Table of Contents

  1. Overview
  2. Plugin Architecture
  3. Creating a Plugin
  4. Plugin Manifest
  5. Slot Components
  6. Event Handlers
  7. Settings Schema
  8. Admin Pages
  9. Runtime Context API
  10. Registering Your Plugin
  11. Best Practices

Overview

ZapBB plugins extend the forum's functionality through:

  • Slots: Inject custom UI components into predefined locations
  • Events: React to forum events (post created, user registered, etc.)
  • Settings: Configurable options stored per-plugin
  • Admin Pages: Custom administration interfaces

Plugins are TypeScript/React packages that integrate at build time for optimal performance and type safety.


Plugin Architecture

my-zapbb-plugin/
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts          # Package entry, exports the plugin definition
│   ├── slots/
│   │   ├── PostFooter.tsx
│   │   └── ThreadHeader.tsx
│   └── admin/
│       └── SettingsPage.tsx
└── dist/

Creating a Plugin

Step 1: Initialize the Package

mkdir my-zapbb-plugin && cd my-zapbb-plugin
bun init

Step 2: Add Dependencies

bun add react
bun add -d typescript @types/react

Step 3: Configure TypeScript

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Plugin Manifest

The manifest defines your plugin's metadata, capabilities, and configuration. Use definePlugin() to create a type-safe definition.

// src/index.ts
import { definePlugin } from "@zapbb/plugin-types";

export default definePlugin({
  // Required fields
  id: "my-zapbb-plugin",        // Unique identifier (kebab-case)
  name: "My ZapBB Plugin",      // Display name
  version: "1.0.0",             // Semantic version

  // Optional metadata
  description: "Adds custom functionality to ZapBB",
  author: "Your Name",
  license: "MIT",
  minForumVersion: "0.1.0",     // Minimum compatible ZapBB version
  maxForumVersion: "1.x.x",     // Maximum compatible ZapBB version

  // Plugin capabilities (all optional)
  slots: { /* ... */ },
  events: { /* ... */ },
  settingsSchema: { /* ... */ },
  adminPages: [ /* ... */ ],
});

Slot Components

Slots are predefined locations in the UI where plugins can inject content.

Available Slot IDs

Slot ID Location Props Received
post.header Top of each post { post }
post.footer Bottom of each post { post }
post.actions Post action buttons area { post }
thread.header Top of thread view { thread }
thread.actions Thread action buttons { thread }
sidebar.top Top of sidebar {}
sidebar.bottom Bottom of sidebar {}
category.header Category page header { category }
admin.dashboard Admin dashboard widgets {}

Creating a Slot Component

Slot components receive props plus a context object with access to the runtime API.

// src/slots/PostFooter.tsx
import type { PluginSlotComponent } from "@zapbb/plugin-types";

interface PostFooterProps {
  post: {
    id: string;
    author_id: string;
    content: string;
  };
}

const PostFooter: PluginSlotComponent<PostFooterProps> = ({ post, context }) => {
  // Access plugin settings
  if (!context.settings.showFooter) {
    return null;
  }

  // Access current user
  const isAuthor = context.currentUser?.user_id === post.author_id;

  return (
    <div className="mt-2 text-xs text-slate-500">
      {isAuthor && <span>✓ You wrote this</span>}
      <span>Plugin footer for post {post.id}</span>
    </div>
  );
};

export default PostFooter;

Registering Slots

Use lazy imports for code splitting:

// src/index.ts
export default definePlugin({
  id: "my-plugin",
  name: "My Plugin",
  version: "1.0.0",

  slots: {
    "post.footer": () => import("./slots/PostFooter"),
    "thread.header": () => import("./slots/ThreadHeader"),
  },
});

Or use direct component references for simpler cases:

import PostFooter from "./slots/PostFooter";

export default definePlugin({
  // ...
  slots: {
    "post.footer": PostFooter,
  },
});

Event Handlers

Plugins can subscribe to forum events and execute custom logic.

Available Events

Event Payload Description
post.created { post } New post created
post.updated { post } Post edited
post.deleted { postId } Post removed
thread.created { thread } New thread started
thread.locked { thread } Thread locked
user.registered { user } New user signed up
user.login { user } User logged in

Creating Event Handlers

// src/index.ts
export default definePlugin({
  id: "my-plugin",
  name: "My Plugin",
  version: "1.0.0",

  events: {
    "post.created": async (payload, context) => {
      const post = payload as { id: string; content: string };
      
      // Check settings before acting
      if (!context.settings.enableNotifications) {
        return;
      }

      // Use the API client
      // await context.api.someEndpoint(...)

      // Emit custom events
      await context.emit("my-plugin.post-processed", { postId: post.id });
    },

    "user.login": async (payload, context) => {
      // Track login in plugin storage
      const count = (context.storage.get("loginCount") as number) || 0;
      context.storage.set("loginCount", count + 1);
    },
  },
});

Settings Schema

Define configurable options for your plugin using a JSON Schema-like format.

export default definePlugin({
  id: "my-plugin",
  name: "My Plugin",
  version: "1.0.0",

  settingsSchema: {
    type: "object",
    properties: {
      enableFeature: {
        type: "boolean",
        title: "Enable Feature",
        description: "Toggle the main plugin functionality",
        default: true,
      },
      apiKey: {
        type: "string",
        title: "API Key",
        description: "External service API key",
      },
      maxItems: {
        type: "number",
        title: "Maximum Items",
        description: "Limit for items to display",
        default: 10,
        minimum: 1,
        maximum: 100,
      },
    },
    required: ["apiKey"],
  },
});

Supported Field Types

Type Renders As Additional Properties
boolean Toggle switch default
string Text input default
number Number input default, minimum, maximum

Settings are automatically rendered in Admin → Plugins → [Your Plugin].


Admin Pages

Add custom pages to the admin panel for advanced configuration or dashboards.

export default definePlugin({
  id: "my-plugin",
  name: "My Plugin",
  version: "1.0.0",

  adminPages: [
    {
      id: "my-plugin-dashboard",
      title: "Plugin Dashboard",
      path: "/admin/plugins/my-plugin/dashboard",
      component: () => import("./admin/Dashboard"),
    },
    {
      id: "my-plugin-settings",
      title: "Advanced Settings",
      path: "/admin/plugins/my-plugin/settings",
      component: () => import("./admin/AdvancedSettings"),
    },
  ],
});

Admin Page Component

// src/admin/Dashboard.tsx
export default function Dashboard() {
  return (
    <div className="space-y-4">
      <h1 className="text-2xl font-bold">Plugin Dashboard</h1>
      <p>Custom admin interface for your plugin.</p>
    </div>
  );
}

Runtime Context API

Every slot component and event handler receives a context object with these properties:

interface PluginRuntimeContext {
  // Plugin identifier
  pluginId: string;

  // Current settings values
  settings: Record<string, string | number | boolean>;

  // OpenAPI-Qraft API client
  api: ApiClient;

  // Current authenticated user (null if not logged in)
  currentUser: { user_id: string; role: string } | null;

  // Emit custom events
  emit: (event: string, payload: unknown) => Promise<void>;

  // Subscribe to events (returns unsubscribe function)
  on: (event: string, handler: EventHandler) => () => void;

  // Plugin-scoped localStorage wrapper
  storage: {
    get: (key: string) => string | number | boolean | null;
    set: (key: string, value: string | number | boolean) => void;
    remove: (key: string) => void;
  };
}

Using the API Client

const MyComponent: PluginSlotComponent<Props> = ({ context }) => {
  const { data } = context.api.api.listThreads.useQuery({
    query: { category_id: "123", limit: 5 },
  });

  return <div>{data?.threads.map(t => <span key={t.id}>{t.title}</span>)}</div>;
};

Using Storage

// Store data
context.storage.set("lastVisit", Date.now());

// Retrieve data
const lastVisit = context.storage.get("lastVisit") as number;

// Remove data
context.storage.remove("lastVisit");

Registering Your Plugin

Step 1: Install the Plugin Package

cd /path/to/zapbb/frontend
bun add my-zapbb-plugin

Or link locally during development:

cd /path/to/my-zapbb-plugin
bun link

cd /path/to/zapbb/frontend
bun link my-zapbb-plugin

Step 2: Register in plugins.config.ts

// frontend/plugins.config.ts
import type { PluginDefinition } from "@/lib/plugins/types";
import myPlugin from "my-zapbb-plugin";

export const plugins: PluginDefinition[] = [
  myPlugin,
  // Add more plugins here
];

Step 3: Rebuild the Application

bun run build

Best Practices

Performance

  • Use lazy imports for slot components to enable code splitting
  • Avoid heavy computations in slot components; defer to effects or workers
  • Minimize re-renders by memoizing expensive calculations

Security

  • Never expose secrets in client-side code
  • Validate all inputs from settings and user interactions
  • Use the API client for authenticated requests instead of raw fetch

Compatibility

  • Specify version constraints in minForumVersion/maxForumVersion
  • Test with multiple ZapBB versions before publishing
  • Handle missing context gracefully (settings may not exist)

Code Quality

  • Use TypeScript for type safety
  • Export types from your package for consumers
  • Document your plugin with a README and examples
  • Follow ZapBB naming conventions (kebab-case for IDs)

Example: Complete Plugin

// src/index.ts
import { definePlugin } from "@zapbb/plugin-types";

export default definePlugin({
  id: "reaction-counter",
  name: "Reaction Counter",
  version: "1.0.0",
  description: "Shows reaction counts on posts",
  author: "ZapBB Team",

  slots: {
    "post.footer": () => import("./slots/ReactionCount"),
  },

  events: {
    "post.created": async (payload, context) => {
      if (context.settings.logNewPosts) {
        console.log("New post:", payload);
      }
    },
  },

  settingsSchema: {
    type: "object",
    properties: {
      showEmoji: {
        type: "boolean",
        title: "Show Emoji",
        default: true,
      },
      logNewPosts: {
        type: "boolean",
        title: "Log New Posts",
        default: false,
      },
    },
  },
});
// src/slots/ReactionCount.tsx
import type { PluginSlotComponent } from "@zapbb/plugin-types";

interface Props {
  post: { id: string; reactions_count: number };
}

const ReactionCount: PluginSlotComponent<Props> = ({ post, context }) => {
  const emoji = context.settings.showEmoji ? "👍 " : "";
  
  return (
    <span className="text-sm text-slate-600">
      {emoji}{post.reactions_count} reactions
    </span>
  );
};

export default ReactionCount;

Support