Plugin Development Guide
A comprehensive guide to building plugins for SDK Kit.
Table of Contents
- Introduction
- Plugin Architecture
- Getting Started
- Plugin Types
- Core Patterns
- Testing
- Documentation
- Tier 1 vs Tier 2 Plugins
- Best Practices
- Examples
- FAQ
Introduction
Plugins are the heart of SDK Kit. They enable modular, reusable functionality that can be included or excluded at build time. This guide teaches you how to build high-quality plugins using battle-tested patterns.
What You’ll Learn
- How to structure a plugin
- Core design patterns
- Testing strategies
- When to create Tier 1 vs Tier 2 plugins
- Best practices from 6 production plugins
Prerequisites
- TypeScript knowledge
- Familiarity with SDK Kit core (
@prosdevlab/sdk-kit) - Understanding of functional programming basics
Plugin Architecture
The Plugin Function Signature
Every plugin is a pure function that receives three parameters:
import type { PluginFunction } from '@prosdevlab/sdk-kit';
export const myPlugin: PluginFunction = (plugin, instance, config) => {
// Plugin implementation
};Parameters:
-
plugin- Plugin capabilities:plugin.ns(namespace)- Set namespace for events/configplugin.defaults(config)- Set default configurationplugin.expose(api)- Add methods to SDK instanceplugin.emit(event, data)- Emit events
-
instance- SDK instance:instance.on(event, handler)- Listen to SDK events- Access to other plugins (if needed)
- Full SDK API
-
config- Configuration:config.get(path)- Read config values- Hierarchical with dot-notation (e.g.,
my.plugin.setting)
Why This Pattern?
- Explicit dependencies - No hidden globals
- Type-safe - TypeScript knows what’s available
- Testable - Can mock each capability
- Functional - No classes, no
thisbinding issues
Getting Started
Step 1: Define Your Plugin’s Purpose
Before writing code, answer these questions:
- What problem does this plugin solve?
- Who will use it?
- What should the API look like?
- What configuration options are needed?
- Is it Tier 1 (generic) or Tier 2 (specific)?
Step 2: Create the File Structure
Tier 1 Plugin
Tier 1 Plugin (in monorepo):
packages/plugins/src/my-plugin/
├── my-plugin.ts # Main implementation
├── index.ts # Barrel export
├── my-plugin.test.ts # Unit tests
└── README.md # DocumentationStep 3: Implement the Plugin
Set namespace
Call plugin.ns() to establish your plugin’s event and config namespace.
plugin.ns('my.plugin');Set defaults
Define default configuration values using plugin.defaults().
plugin.defaults({
my: {
plugin: {
enabled: true,
setting: 'default value',
}
}
});Expose public API
Add methods to the SDK instance with plugin.expose().
plugin.expose({
myPlugin: {
doSomething() {
const setting = config.get('my.plugin.setting');
plugin.emit('my-plugin:action', { setting });
}
}
});Listen to lifecycle
Hook into SDK events like sdk:ready and sdk:destroy.
instance.on('sdk:ready', () => {
console.log('My plugin is ready!');
});
instance.on('sdk:destroy', () => {
// Cleanup
});Step 4: Define Types
// Define the plugin's interface
export interface MyPlugin {
doSomething(): void;
}
// Define config interface
export interface MyPluginConfig {
my?: {
plugin?: {
enabled?: boolean;
setting?: string;
};
};
}
// Export everything
export { myPlugin };Plugin Types
1. Storage-Like Plugins (CRUD Operations)
Use case: Managing data with multiple backends
Pattern: Backend abstraction with fallback
Structure:
plugin/
├── index.ts # Main plugin
├── backends/
│ ├── types.ts # Backend interface
│ ├── backend1.ts # Implementation 1
│ ├── backend2.ts # Implementation 2
│ └── fallback.ts # Fallback (e.g., memory)
├── plugin.test.ts
└── README.mdExample: Storage Plugin
// backends/types.ts
export interface StorageBackend {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
clear(): void;
isSupported(): boolean;
}
// backends/localStorage.ts
export class LocalStorageBackend implements StorageBackend {
private fallback: MemoryBackend | null = null;
get(key: string): string | null {
if (!this.isSupported()) {
return this.getFallback().get(key);
}
try {
return localStorage.getItem(key);
} catch (error) {
console.warn('localStorage failed:', error);
return this.getFallback().get(key);
}
}
isSupported(): boolean {
try {
const test = '__test__';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch {
return false;
}
}
private getFallback(): MemoryBackend {
if (!this.fallback) {
this.fallback = new MemoryBackend();
}
return this.fallback;
}
// ... other methods
}
// storage.ts (main plugin)
export const storagePlugin: PluginFunction = (plugin, instance, config) => {
plugin.ns('storage');
const backends: Partial<Record<BackendType, StorageBackend>> = {};
function getBackend(type: BackendType): StorageBackend {
if (!backends[type]) {
switch (type) {
case 'localStorage':
backends[type] = new LocalStorageBackend();
break;
case 'sessionStorage':
backends[type] = new SessionStorageBackend();
break;
// ... more backends
}
}
return backends[type]!;
}
plugin.expose({
storage: {
set(key, value, options) {
const backend = getBackend(options?.backend ?? 'localStorage');
backend.set(key, JSON.stringify({ value }));
plugin.emit('storage:set', { key, backend: options?.backend });
},
get(key, options) {
const backend = getBackend(options?.backend ?? 'localStorage');
const raw = backend.get(key);
if (!raw) return null;
try {
const { value } = JSON.parse(raw);
plugin.emit('storage:get', { key, backend: options?.backend });
return value;
} catch {
return null;
}
},
// ... more methods
}
});
};Key Patterns:
- Backend abstraction via interface
- Lazy initialization of backends
- Graceful fallback to memory
- Error handling at every step
- Event emission for observability
2. Collector Plugins (Gather Data)
Use case: Collecting context/information
Pattern: Multiple collectors with unified output
Structure:
plugin/
├── index.ts # Main plugin
├── collectors/
│ ├── collector1.ts # Collector 1
│ ├── collector2.ts # Collector 2
│ └── collector3.ts # Collector 3
├── util/ # Shared utilities
│ └── helpers.ts
├── plugin.test.ts
└── README.mdExample: Context Plugin
// collectors/page.ts
export function collectPage(): PageContext {
if (typeof window === 'undefined') {
return {
url: '',
path: '',
query: {},
referrer: '',
title: '',
};
}
const url = window.location.href;
const parsed = new URL(url);
return {
url,
path: parsed.pathname,
query: Object.fromEntries(parsed.searchParams),
referrer: document.referrer,
title: document.title,
};
}
// context.ts (main plugin)
export const contextPlugin: PluginFunction = (plugin, instance, config) => {
plugin.ns('context');
let cache: Context | null = null;
function collect(): Context {
if (!cache) {
cache = {
page: collectPage(),
device: collectDevice(),
screen: collectScreen(),
environment: collectEnvironment(),
timestamp: Date.now(),
};
}
return cache;
}
function refresh(): void {
cache = null;
}
plugin.expose({
context: {
get: collect,
getPage: () => collect().page,
getDevice: () => collect().device,
getScreen: () => collect().screen,
getEnvironment: () => collect().environment,
refresh,
}
});
};Key Patterns:
- Separate collectors for each data type
- Caching to avoid redundant work
- Manual refresh when needed
- Graceful handling of missing APIs
3. Utility Plugins (Single Purpose)
Use case: Simple, focused functionality
Pattern: Single file with config
Structure:
plugin/
├── poll.ts # Main plugin (all logic here)
├── index.ts # Barrel export
├── poll.test.ts
└── README.mdExample: Poll Plugin
export const pollPlugin: PluginFunction = (plugin, instance, config) => {
plugin.ns('poll');
plugin.defaults({
poll: {
defaultInterval: 100,
defaultTimeout: 5000,
}
});
const activePolls = new Set<ReturnType<typeof setInterval>>();
async function waitFor(
condition: () => boolean,
options?: { interval?: number; timeout?: number }
): Promise<boolean> {
const interval = options?.interval ?? config.get('poll.defaultInterval');
const timeout = options?.timeout ?? config.get('poll.defaultTimeout');
// Try immediately
if (condition()) {
plugin.emit('poll:success', { immediate: true });
return true;
}
return new Promise((resolve) => {
const startTime = Date.now();
const timer = setInterval(() => {
if (condition()) {
clearInterval(timer);
activePolls.delete(timer);
plugin.emit('poll:success', { elapsed: Date.now() - startTime });
resolve(true);
} else if (Date.now() - startTime > timeout) {
clearInterval(timer);
activePolls.delete(timer);
plugin.emit('poll:timeout', { elapsed: Date.now() - startTime });
resolve(false);
}
}, interval);
activePolls.add(timer);
});
}
plugin.expose({
poll: {
waitFor,
element: (selector: string, options?) =>
waitFor(() => document.querySelector(selector) !== null, options),
global: (name: string, options?) =>
waitFor(() => (window as any)[name] !== undefined, options),
}
});
instance.on('sdk:destroy', () => {
activePolls.forEach(clearInterval);
activePolls.clear();
});
};Key Patterns:
- Promise-based API
- Immediate check (no delay)
- Convenience methods
- Lifecycle cleanup
- Event emission
4. Orchestration Plugins (Coordinate Others)
Use case: Combining multiple plugins
Pattern: Depends on other plugins, adds coordination logic
Structure:
plugin/
├── queue.ts # Main plugin
├── index.ts # Barrel export
├── queue.test.ts
└── README.mdExample: Queue Plugin
export const queuePlugin: PluginFunction = (plugin, instance, config) => {
plugin.ns('queue');
plugin.defaults({
queue: {
maxSize: 20,
flushInterval: 5000,
persist: false,
maxQueueSize: 100,
}
});
const state = {
items: [] as QueueItem[],
flushTimer: null as ReturnType<typeof setInterval> | null,
};
function add(data: unknown): string {
const item: QueueItem = {
id: `${Date.now()}-${Math.random()}`,
data,
timestamp: Date.now(),
retries: 0,
};
state.items.push(item);
plugin.emit('queue:add', item);
// Auto-flush if maxSize reached
const maxSize = config.get('queue.maxSize');
if (maxSize && state.items.length >= maxSize) {
flush();
}
// FIFO if maxQueueSize exceeded
const maxQueueSize = config.get('queue.maxQueueSize');
if (maxQueueSize && state.items.length > maxQueueSize) {
state.items.shift(); // Remove oldest
}
// Persist if enabled
if (config.get('queue.persist') && instance.storage) {
instance.storage.set('_sdk_queue', state.items);
}
return item.id;
}
function flush(): void {
if (state.items.length === 0) return;
const items = [...state.items];
state.items = [];
plugin.emit('queue:flush', { items });
// Clear persisted queue
if (config.get('queue.persist') && instance.storage) {
instance.storage.remove('_sdk_queue');
}
}
plugin.expose({
queue: {
add,
flush,
size: () => state.items.length,
clear: () => {
state.items = [];
if (instance.storage) {
instance.storage.remove('_sdk_queue');
}
},
}
});
// Start flush timer
instance.on('sdk:ready', () => {
const interval = config.get('queue.flushInterval');
if (interval) {
state.flushTimer = setInterval(flush, interval);
}
// Restore from storage
if (config.get('queue.persist') && instance.storage) {
const persisted = instance.storage.get('_sdk_queue');
if (Array.isArray(persisted)) {
state.items = persisted;
}
}
});
// Cleanup
instance.on('sdk:destroy', () => {
if (state.flushTimer) {
clearInterval(state.flushTimer);
}
flush(); // Flush remaining items
});
// Flush on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flush);
}
};Key Patterns:
- Depends on Storage plugin (optional)
- Auto-flush based on size or time
- Persistence across page reloads
- Lifecycle integration
- Multiple triggers (size, time, destroy, unload)
Core Patterns
Pattern 1: Capability Injection
Problem: How do plugins access SDK features?
Solution: Pass capabilities as function parameters
export const myPlugin: PluginFunction = (plugin, instance, config) => {
// 'plugin', 'instance', 'config' are injected capabilities
plugin.ns('my.plugin'); // Use plugin capabilities
instance.on('sdk:ready', () =>{}); // Use SDK instance
config.get('my.plugin.setting'); // Use config
};Benefits:
- Explicit dependencies (no hidden globals)
- Type-safe
- Testable (mock each capability)
- Tree-shakeable
Pattern 2: Lazy Initialization
Problem: Creating all resources upfront is wasteful
Solution: Create resources only when needed
const backends: Partial<Record<BackendType, Backend>> = {};
function getBackend(type: BackendType): Backend {
if (!backends[type]) {
backends[type] = createBackend(type); // Create on first use
}
return backends[type]!;
}Benefits:
- Faster initialization
- Lower memory usage
- Supports tree-shaking
- Only pay for what you use
Pattern 3: Graceful Degradation
Problem: Native APIs can fail in edge cases
Solution: Always have a working fallback
try {
localStorage.setItem(key, value);
} catch (error) {
console.warn('localStorage failed, falling back to memory');
this.fallback.set(key, value); // Always works
}Benefits:
- Never throw errors to user code
- SDK continues to function
- Users don’t see failures
- Better UX in edge cases (private mode, quota exceeded)
Pattern 4: Metadata Wrapping
Problem: Native APIs don’t support features we need
Solution: Wrap values with metadata
interface StoredValue<T> {
value: T;
expires?: number; // Add TTL support
}
// Store
const stored = { value: data, expires: Date.now() + 3600000 };
backend.set(key, JSON.stringify(stored));
// Retrieve
const stored = JSON.parse(backend.get(key));
if (stored.expires && Date.now() > stored.expires) {
return null; // Expired!
}
return stored.value;Benefits:
- Add features transparently
- Backward compatible
- No API changes needed
- Flexible metadata
Pattern 5: Event-Driven Observability
Problem: Users need visibility into plugin behavior
Solution: Emit events for all significant operations
plugin.emit('storage:set', { key, value, backend });
plugin.emit('storage:get', { key, backend });
plugin.emit('storage:expired', { key, backend });
// Users can observe
sdk.on('storage:*', (event, data) => {
console.log('Storage event:', event, data);
});Benefits:
- Users can observe behavior
- Enables debugging
- Supports monitoring/analytics
- Loose coupling between plugins
Testing
Test Structure
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SDK } from '@prosdevlab/sdk-kit';
import { myPlugin, type MyPlugin } from './my-plugin';
interface SDKWithPlugin extends SDK {
myPlugin: MyPlugin;
}
describe('My Plugin', () => {
let sdk: SDKWithPlugin;
beforeEach(() => {
const newSdk = new SDK();
newSdk.use(myPlugin);
sdk = newSdk as SDKWithPlugin;
});
afterEach(() => {
sdk.destroy();
});
it('should do something', async () => {
await sdk.init();
sdk.myPlugin.doSomething();
expect(true).toBe(true);
});
});Test Coverage Categories
- Happy Path - Basic functionality works
- Edge Cases - Boundary conditions, special values
- Error Handling - Graceful failure, fallbacks
- Events - All operations emit correct events
- Lifecycle - Init, ready, destroy hooks
- Configuration - Defaults, user config, runtime updates
- Integration - Works with other plugins
Testing Best Practices
// 1. Use jsdom for browser APIs
// vitest.config.ts
export default {
test: {
environment: 'jsdom',
},
};
// 2. Mock console warnings
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
// 3. Use fake timers for TTL/delays
it('should expire after TTL', () => {
vi.useFakeTimers();
sdk.storage.set('key', 'value', { ttl: 10 });
expect(sdk.storage.get('key')).toBe('value');
vi.advanceTimersByTime(11000);
expect(sdk.storage.get('key')).toBeNull();
vi.useRealTimers();
});
// 4. Test events
it('should emit events', () => {
const handler = vi.fn();
sdk.on('my-plugin:action', handler);
sdk.myPlugin.doSomething();
expect(handler).toHaveBeenCalledWith({ /* event data */ });
});
// 5. Test with different configs
it('should respect user config', async () => {
await sdk.init({
my: { plugin: { setting: 'custom' } }
});
// Plugin should use 'custom' setting
});Target Coverage
- >90% line coverage - Aim high
- >80% branch coverage - Test all paths
- >95% function coverage - Test all exposed methods
Documentation
Plugin README Template
# My Plugin
Brief description of what the plugin does.
## Features
- Feature 1
- Feature 2
- Feature 3
## Installation
\`\`\`bash
npm install @prosdevlab/sdk-kit @prosdevlab/sdk-kit-plugins
\`\`\`
## Usage
\`\`\`typescript
import { SDK } from '@prosdevlab/sdk-kit';
import { myPlugin } from '@prosdevlab/sdk-kit-plugins';
const sdk = new SDK();
sdk.use(myPlugin);
await sdk.init({
my: {
plugin: {
setting: 'value'
}
}
});
// Use the plugin
sdk.myPlugin.doSomething();
\`\`\`
## API
### \`doSomething()\`
Description of what this method does.
**Parameters:**
- \`param1\` (type) - Description
- \`param2\` (type, optional) - Description
**Returns:** Type - Description
**Example:**
\`\`\`typescript
sdk.myPlugin.doSomething(param1, param2);
\`\`\`
## Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| \`setting\` | string | \`'default'\` | Description |
## Events
### \`my-plugin:action\`
Emitted when... Description.
**Payload:**
\`\`\`typescript
{
key: string;
value: any;
}
\`\`\`
## Browser Compatibility
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
## License
MITTier 1 vs Tier 2 Plugins
Tier 1: Generic, Battle-Tested (Monorepo)
Characteristics:
- No company-specific logic
- No hardcoded endpoints
- Configurable for any use case
- Lives in
@prosdevlab/sdk-kit-plugins
Examples:
- Storage (localStorage, cookies)
- Context (URL, user agent)
- Transport (HTTP, beacon)
- Queue (batching)
- Poll (async conditions)
- Consent (GDPR)
When to create:
- Solves a common problem
- Useful for any SDK
- No company-specific assumptions
- Can be maintained long-term
Tier 2: Custom/Community (Separate Packages)
Characteristics:
- Company-specific logic
- Hardcoded endpoints/formats
- Specific to one product
- Lives in separate npm package (e.g.,
@company/sdk-kit-plugin-name)
Examples:
@prosdevlab/sdk-kit-plugin-lytics- Lytics API integration@company/sdk-kit-plugin-analytics- Company analytics@company/sdk-kit-plugin-auth- Company auth
When to create:
- Company-specific use case
- Builds on Tier 1 plugins
- Different release cycle
- Product-specific features
Example: Tier 1 vs Tier 2
Tier 1 (Generic)
export const transportPlugin: PluginFunction = (plugin, instance, config) => {
plugin.expose({
transport: {
send(request: TransportRequest) {
// Generic HTTP transport - works with ANY backend
return fetch(request.url, {
method: request.method,
body: request.data,
});
}
}
});
};Best Practices
1. Start with Design
Before writing code:
- Define the problem
- Design the API
- Identify dependencies
- Plan configuration
- List events
2. Follow Functional Patterns
// Good: Pure function
export const myPlugin: PluginFunction = (plugin, instance, config) => {
// No state outside function
};
// Bad: Class with state
export class MyPlugin {
private state = {};
register(sdk: SDK) {
// Hidden state, harder to test
}
}3. Handle Errors Gracefully
// Good: Catch and fallback
try {
localStorage.setItem(key, value);
} catch {
this.fallback.set(key, value);
}
// Bad: Let errors propagate
localStorage.setItem(key, value); // Throws in private mode!4. Emit Events Liberally
// Good: Events for observability
plugin.emit('storage:set', { key, value });
plugin.emit('storage:get', { key });
plugin.emit('storage:expired', { key });
// Users can monitor
sdk.on('storage:*', (event, data) => {
console.log(event, data);
});5. Document Thoroughly
/**
* Store a value with optional TTL
*
* @param key - Storage key
* @param value - Value to store (any JSON-serializable type)
* @param options - Storage options
* @param options.backend - Backend to use (default: localStorage)
* @param options.ttl - Time to live in seconds
*
* @example
* sdk.storage.set('user', { id: 123 }, { ttl: 3600 });
*/
set<T>(key: string, value: T, options?: StorageOptions): void6. Test Thoroughly
- Aim for >90% coverage
- Test all error paths
- Test with different configs
- Test events
- Test lifecycle hooks
7. Keep Plugins Focused
// Good: Single responsibility
export const storagePlugin // Handles storage
export const queuePlugin // Handles queuing
// Bad: Too many responsibilities
export const megaPlugin // Storage + Queue + Transport + AnalyticsExamples
Example 1: Simple Utility Plugin
export const timestampPlugin: PluginFunction = (plugin, instance, config) => {
plugin.ns('timestamp');
plugin.expose({
timestamp: {
now: () => Date.now(),
iso: () => new Date().toISOString(),
unix: () => Math.floor(Date.now() / 1000),
}
});
};Example 2: Plugin with Configuration
export const loggerPlugin: PluginFunction = (plugin, instance, config) => {
plugin.ns('logger');
plugin.defaults({
logger: {
level: 'info',
prefix: '[SDK]',
}
});
const logs: string[] = [];
function log(level: string, message: string, data?: any) {
const prefix = config.get('logger.prefix');
const configLevel = config.get('logger.level');
// Check if this level should be logged
const levels = ['debug', 'info', 'warn', 'error'];
if (levels.indexOf(level) < levels.indexOf(configLevel)) {
return; // Skip
}
const formatted = `${prefix} [${level.toUpperCase()}] ${message}`;
logs.push(formatted);
console[level](formatted, data);
plugin.emit('logger:log', { level, message, data });
}
plugin.expose({
debug: (msg, data?) => log('debug', msg, data),
info: (msg, data?) => log('info', msg, data),
warn: (msg, data?) => log('warn', msg, data),
error: (msg, data?) => log('error', msg, data),
getLogs: () => [...logs],
});
};Example 3: Plugin Depending on Another Plugin
export const analyticsPlugin: PluginFunction = (plugin, instance, config) => {
plugin.ns('analytics');
plugin.defaults({
analytics: {
endpoint: 'https://api.example.com/events',
attachContext: true,
}
});
plugin.expose({
analytics: {
track(event: string, properties?: any) {
// Use queue plugin (if available)
if (instance.queue) {
instance.queue.add({ event, properties });
}
},
}
});
// Flush queue via transport
instance.on('queue:flush', async ({ items }) => {
const endpoint = config.get('analytics.endpoint');
const attachContext = config.get('analytics.attachContext');
const payload = {
events: items,
context: attachContext && instance.context
? instance.context.get()
: undefined,
};
await instance.transport.send({
url: endpoint,
method: 'POST',
data: payload,
});
});
};FAQ
Q: Should I create a Tier 1 or Tier 2 plugin?
A: Ask yourself:
- Is it useful for any SDK? → Tier 1
- Is it company-specific? → Tier 2
When in doubt, start with Tier 2. You can always extract generic parts into Tier 1 later.
Q: Can plugins depend on other plugins?
A: Yes, but:
- Check if the plugin exists before using it
- Make dependencies optional when possible
- Document dependencies clearly
if (instance.storage) {
// Use storage plugin
} else {
// Fallback behavior
}Q: How do I test browser APIs in Node.js?
A: Use jsdom:
// vitest.config.ts
export default {
test: {
environment: 'jsdom',
},
};Q: Should I use classes or functions for backends?
A: Either works, but classes are convenient for:
- State management (fallback instances)
- Shared utilities
- Interface implementation
Functions are better for stateless utilities.
Q: How do I handle async operations in plugins?
A: Return promises from exposed methods:
plugin.expose({
myPlugin: {
async fetchData() {
const response = await fetch('/api/data');
return response.json();
}
}
});Q: Can I modify config at runtime?
A: Users can with sdk.set(). Your plugin reads the latest value:
// Plugin reads config
const setting = config.get('my.plugin.setting');
// User updates at runtime
sdk.set('my.plugin.setting', 'new value');Q: How do I clean up resources on destroy?
A: Listen to sdk:destroy:
instance.on('sdk:destroy', () => {
// Clear timers
clearInterval(myTimer);
// Remove event listeners
window.removeEventListener('beforeunload', myHandler);
// Flush pending data
flush();
});Next Steps
Review Examples
Check out the 6 essential plugins in the plugins package source.
Start Small
Build a simple utility plugin first to get familiar with the patterns.
Follow Patterns
Use the implementation patterns from this guide as templates.
Ask Questions
Open issues or discussions on GitHub if you get stuck.
Contribute
Submit PRs for Tier 1 plugins that could benefit the community.
Resources
Last Updated: January 1, 2026