Initial commit

This commit is contained in:
2026-04-18 20:09:16 +10:00
commit eace5d0f35
11 changed files with 1670 additions and 0 deletions

186
src/PluginContext.ts Normal file
View File

@@ -0,0 +1,186 @@
import type { PluginContext, NotificationOptions } from './PluginInterfaces.js';
import type { IPluginEventClient } from './interfaces.js';
import type { PluginRegistry } from './PluginRegistry.js';
/**
* Options for constructing a PluginContext via `createPluginContext`.
*/
export interface PluginContextOptions {
/** The plugin's unique identifier. */
pluginId: string;
/**
* Optional event client for the `context.events` API.
* Any object implementing `on(event, handler)` / `off(event, handler)` works —
* e.g. a MatrixClient, Node.js EventEmitter, or socket.io socket.
*/
eventClient?: IPluginEventClient;
/**
* Called when a plugin invokes `context.notify(...)`.
* The host is responsible for displaying the notification however it wants.
*/
onNotify?: (options: NotificationOptions) => void | Promise<void>;
}
/**
* Creates a PluginContext wired to the given registry.
* Call this before registering and loading a plugin.
*
* @param options - Host-specific dependencies for the plugin.
* @param registry - The shared PluginRegistry instance.
* @returns A fully-wired PluginContext ready to be passed to `plugin.onLoad`.
*/
export function createPluginContext(
options: PluginContextOptions,
registry: PluginRegistry
): PluginContext {
const { pluginId, eventClient, onNotify } = options;
return {
pluginId,
commands: {
register: (command) => {
registry.registerCommand(pluginId, command);
registry.addLog(pluginId, 'log', [`Registered command: /${command.name}`]);
},
unregister: (name) => {
registry.unregisterCommand(name);
},
execute: async (name, args) => {
return registry.executeCommand(name, JSON.stringify(args));
},
},
messages: {
onBeforeSend: (interceptor) => {
registry.addBeforeSendInterceptor(pluginId, interceptor);
registry.addLog(pluginId, 'log', ['Registered beforeSend interceptor']);
},
onReceive: (interceptor) => {
registry.addReceiveInterceptor(pluginId, interceptor);
registry.addLog(pluginId, 'log', ['Registered receive interceptor']);
},
},
ui: {
registerRenderer: (type, renderer) => {
registry.registerRenderer(pluginId, type, renderer);
registry.addLog(pluginId, 'log', [`Registered renderer: ${type}`]);
},
unregisterRenderer: (type) => {
registry.unregisterRenderer(type);
},
},
settings: {
define: (schema) => {
registry.defineSettings(pluginId, schema);
registry.addLog(pluginId, 'log', ['Defined settings schema']);
},
get: <T = unknown>(key: string): T | undefined => {
return registry.getPluginSetting<T>(pluginId, key);
},
set: (key, value) => {
registry.setPluginSetting(pluginId, key, value);
},
},
themes: {
register: (theme) => {
registry.registerTheme(pluginId, theme);
registry.addLog(pluginId, 'log', [`Registered theme: ${theme.name}`]);
},
unregister: (themeId) => {
registry.unregisterTheme(pluginId, themeId);
},
},
events: {
on: (eventType, handler) => {
if (!eventClient) {
console.warn(`[Plugin ${pluginId}] No event client provided — events.on() is a no-op`);
return;
}
registry.addEventHandler(pluginId, eventType, handler);
eventClient.on(eventType, handler);
registry.addLog(pluginId, 'log', [`Listening to event: ${eventType}`]);
},
off: (eventType, handler) => {
if (!eventClient) return;
registry.removeEventHandler(pluginId, eventType, handler);
eventClient.off(eventType, handler);
},
},
timers: {
setInterval: (callback, ms) => {
const id = setInterval(() => {
try {
callback();
} catch (err) {
registry.addLog(pluginId, 'error', ['Interval error:', err]);
}
}, ms) as unknown as number;
registry.addTimer(pluginId, id);
return id;
},
setTimeout: (callback, ms) => {
const id = setTimeout(() => {
try {
callback();
} catch (err) {
registry.addLog(pluginId, 'error', ['Timeout error:', err]);
}
registry.removeTimer(pluginId, id);
}, ms) as unknown as number;
registry.addTimer(pluginId, id);
return id;
},
clearInterval: (id) => {
clearInterval(id);
registry.removeTimer(pluginId, id);
},
clearTimeout: (id) => {
clearTimeout(id);
registry.removeTimer(pluginId, id);
},
},
notify: async (options) => {
const opts: NotificationOptions =
typeof options === 'string' ? { title: 'Plugin', body: options } : options;
registry.addLog(pluginId, 'log', ['Notification:', opts]);
if (onNotify) {
try {
await onNotify(opts);
} catch (err) {
console.error(`[Plugin ${pluginId}] Notification handler error:`, err);
}
}
},
log: (...args) => {
console.log(`[Plugin ${pluginId}]`, ...args);
registry.addLog(pluginId, 'log', args);
},
warn: (...args) => {
console.warn(`[Plugin ${pluginId}]`, ...args);
registry.addLog(pluginId, 'warn', args);
},
error: (...args) => {
console.error(`[Plugin ${pluginId}]`, ...args);
registry.addLog(pluginId, 'error', args);
},
require: (requiredPluginId) => {
const exports = registry.getPluginExports(requiredPluginId);
if (exports === undefined) {
throw new Error(`Plugin ${requiredPluginId} not found or has no exports`);
}
registry.addLog(pluginId, 'log', [`Required plugin: ${requiredPluginId}`]);
return exports;
},
};
}

242
src/PluginInterfaces.ts Normal file
View File

@@ -0,0 +1,242 @@
/**
* Color group with five container variants and an OnContainer text color.
*/
export interface ThemeColorGroup {
container: string;
containerHover: string;
containerActive: string;
containerLine: string;
onContainer: string;
}
/**
* Color group for interactive palette entries (Main + Container sub-groups).
*/
export interface ThemePaletteGroup {
main: string;
mainHover: string;
mainActive: string;
mainLine: string;
onMain: string;
container: string;
containerHover: string;
containerActive: string;
containerLine: string;
onContainer: string;
}
/**
* Full color token set for a plugin theme.
* Property names map directly to the folds design-token contract.
*/
export interface PluginThemeColors {
background: ThemeColorGroup;
surface: ThemeColorGroup;
surfaceVariant: ThemeColorGroup;
primary: ThemePaletteGroup;
secondary: ThemePaletteGroup;
success: ThemePaletteGroup;
warning: ThemePaletteGroup;
critical: ThemePaletteGroup;
other: {
focusRing: string;
shadow: string;
overlay: string;
};
}
/**
* Theme definition supplied by a plugin.
*/
export interface PluginTheme {
id: string;
name: string;
kind: 'light' | 'dark';
colors: PluginThemeColors;
/** Optional extra CSS injected alongside the theme variables. */
extraCss?: string;
}
/**
* A command argument definition.
*/
export interface CommandArg {
name: string;
type?: 'string' | 'number' | 'boolean';
required?: boolean;
description?: string;
}
/**
* A command registered by a plugin.
*/
export interface PluginCommand {
name: string;
description?: string;
args?: string[] | CommandArg[];
run: (args: Record<string, unknown>, ctx: PluginContext) => string | void | Promise<string | void>;
}
/** Callback invoked for outgoing or incoming messages. */
export type MessageInterceptor = (msg: MessageContext) => void | Promise<void>;
/**
* Message context passed to interceptors.
*/
export interface MessageContext {
content: string;
roomId: string;
eventType: string;
formatted?: string;
metadata?: Record<string, unknown>;
}
/**
* Custom renderer function.
* The return type is `unknown` so the package stays framework-agnostic.
* Hosts using React should cast to `ReactNode` at the call-site.
*/
export type CustomRenderer = (data: unknown, defaultRenderer?: () => unknown) => unknown;
/**
* A single setting definition within a plugin's settings schema.
*/
export interface SettingDefinition {
type: 'string' | 'number' | 'boolean' | 'select' | 'color';
label?: string;
description?: string;
default?: unknown;
options?: Array<{ value: unknown; label: string }>;
}
/** The full settings schema for a plugin. */
export type SettingsSchema = Record<string, SettingDefinition>;
/**
* Options for a notification emitted by a plugin.
*/
export interface NotificationOptions {
title: string;
body: string;
type?: 'info' | 'success' | 'error' | 'warning';
duration?: number;
actions?: Array<{ label: string; action: () => void }>;
}
/**
* The context provided to plugins when they are initialized.
* All APIs are available through this object.
*/
export interface PluginContext {
/** Plugin ID */
pluginId: string;
/** Commands API */
commands: {
register: (command: PluginCommand) => void;
unregister: (name: string) => void;
execute: (name: string, args: Record<string, unknown>) => Promise<string | void>;
};
/** Messages API */
messages: {
onBeforeSend: (interceptor: MessageInterceptor) => void;
onReceive: (interceptor: MessageInterceptor) => void;
};
/** UI API — framework-agnostic renderer registry */
ui: {
registerRenderer: (type: string, renderer: CustomRenderer) => void;
unregisterRenderer: (type: string) => void;
};
/** Settings API */
settings: {
define: (schema: SettingsSchema) => void;
get: <T = unknown>(key: string) => T | undefined;
set: (key: string, value: unknown) => void;
};
/** Themes API */
themes: {
register: (theme: PluginTheme) => void;
unregister: (themeId: string) => void;
};
/**
* Generic event emitter API.
* Wire up any event client (Matrix, socket.io, Node EventEmitter, etc.)
* via the `eventClient` option passed to `createPluginContext`.
*/
events: {
on: (eventType: string, handler: (...args: any[]) => void) => void;
off: (eventType: string, handler: (...args: any[]) => void) => void;
};
/** Background tasks API */
timers: {
setInterval: (callback: () => void, ms: number) => number;
setTimeout: (callback: () => void, ms: number) => number;
clearInterval: (id: number) => void;
clearTimeout: (id: number) => void;
};
/** Send a notification through the host application. */
notify: (options: NotificationOptions | string) => void | Promise<void>;
/** Logging API */
log: (...args: any[]) => void;
warn: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Require another loaded plugin's exports. */
require: (pluginId: string) => unknown;
}
/**
* Custom settings section that a plugin can inject into the settings UI.
* The `component` value is `unknown` to remain framework-agnostic.
* Hosts using React should treat it as `ReactNode`.
*/
export interface PluginSettingsSection {
id: string;
name: string;
icon?: string;
component: unknown;
}
/** UI locations where plugins can inject content. */
export type UILocation =
| 'room-header'
| 'message-actions'
| 'user-menu'
| 'room-menu'
| 'composer-actions';
/**
* The main plugin interface that all plugins must implement.
*/
export interface Plugin {
name?: string;
version?: string;
dependencies?: Record<string, string>;
/** Called when the plugin is loaded. */
onLoad: (context: PluginContext) => void | Promise<void>;
/** Called when the plugin is unloaded or disabled. */
onUnload?: () => void | Promise<void>;
/** Exposed API surface for other plugins to consume via `context.require()`. */
exports?: unknown;
}
/**
* A single log entry produced by a plugin.
*/
export interface PluginLogEntry {
pluginId: string;
level: 'log' | 'warn' | 'error';
timestamp: number;
args: unknown[];
}

429
src/PluginRegistry.ts Normal file
View File

@@ -0,0 +1,429 @@
import type {
Plugin,
PluginContext,
PluginCommand,
MessageInterceptor,
MessageContext,
CustomRenderer,
SettingsSchema,
PluginTheme,
PluginLogEntry,
} from './PluginInterfaces.js';
import type { IPluginStorage, IPluginEventClient } from './interfaces.js';
import { MemoryStorage } from './interfaces.js';
import { generateThemeCSS } from './theme-css.js';
/**
* Configuration options for the PluginRegistry.
*/
export interface PluginRegistryOptions {
/**
* Storage adapter for persisting plugin settings.
* Defaults to in-memory storage if not provided.
* Pass `localStorage` (or a wrapper) for browser environments.
*/
storage?: IPluginStorage;
/**
* Called when a plugin registers a theme.
* The host is responsible for injecting the CSS into the document.
* Receives the full theme ID (used as the CSS class name) and the generated CSS string.
*/
onThemeRegistered?: (themeId: string, className: string, css: string) => void;
/**
* Called when a plugin unregisters a theme.
* The host is responsible for removing the injected CSS.
*/
onThemeUnregistered?: (themeId: string) => void;
}
/**
* Central registry that manages all loaded plugins and their side-effects.
* Create a single instance and share it across your application.
*/
export class PluginRegistry {
private readonly plugins = new Map<string, { plugin: Plugin; context: PluginContext }>();
private readonly commands = new Map<string, { pluginId: string; command: PluginCommand }>();
private beforeSendInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = [];
private receiveInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = [];
private readonly renderers = new Map<string, { pluginId: string; renderer: CustomRenderer }>();
private readonly settingsSchemas = new Map<string, SettingsSchema>();
private readonly pluginSettings = new Map<string, Record<string, unknown>>();
private readonly matrixHandlers = new Map<
string,
Array<{ pluginId: string; handler: (...args: any[]) => void }>
>();
private readonly timers = new Map<string, number[]>();
private readonly themes = new Map<
string,
{ pluginId: string; theme: PluginTheme }
>();
private logs: PluginLogEntry[] = [];
private readonly maxLogs = 1000;
private readonly storage: IPluginStorage;
private readonly onThemeRegistered?: PluginRegistryOptions['onThemeRegistered'];
private readonly onThemeUnregistered?: PluginRegistryOptions['onThemeUnregistered'];
constructor(options: PluginRegistryOptions = {}) {
this.storage = options.storage ?? new MemoryStorage();
this.onThemeRegistered = options.onThemeRegistered;
this.onThemeUnregistered = options.onThemeUnregistered;
}
// ---------------------------------------------------------------------------
// Plugin lifecycle
// ---------------------------------------------------------------------------
/** Register a plugin and its context. Called after the host has loaded the module. */
registerPlugin(id: string, plugin: Plugin, context: PluginContext): void {
if (this.plugins.has(id)) {
console.warn(`[PluginRegistry] Plugin ${id} is already registered`);
return;
}
this.plugins.set(id, { plugin, context });
this.timers.set(id, []);
}
/** Unregister a plugin, calling its onUnload hook and cleaning up all side-effects. */
async unregisterPlugin(id: string): Promise<void> {
const entry = this.plugins.get(id);
if (!entry) return;
if (entry.plugin.onUnload) {
try {
await entry.plugin.onUnload();
} catch (err) {
console.error(`[PluginRegistry] Error unloading plugin ${id}:`, err);
}
}
for (const [cmdName, cmd] of this.commands.entries()) {
if (cmd.pluginId === id) this.commands.delete(cmdName);
}
this.beforeSendInterceptors = this.beforeSendInterceptors.filter(i => i.pluginId !== id);
this.receiveInterceptors = this.receiveInterceptors.filter(i => i.pluginId !== id);
for (const [type, renderer] of this.renderers.entries()) {
if (renderer.pluginId === id) this.renderers.delete(type);
}
for (const [themeId, theme] of this.themes.entries()) {
if (theme.pluginId === id) {
this.onThemeUnregistered?.(themeId);
this.themes.delete(themeId);
}
}
const timerIds = this.timers.get(id) ?? [];
timerIds.forEach(timerId => clearInterval(timerId));
this.timers.delete(id);
for (const [eventType, handlers] of this.matrixHandlers.entries()) {
const filtered = handlers.filter(h => h.pluginId !== id);
if (filtered.length === 0) {
this.matrixHandlers.delete(eventType);
} else {
this.matrixHandlers.set(eventType, filtered);
}
}
this.plugins.delete(id);
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/** Register a command contributed by a plugin. */
registerCommand(pluginId: string, command: PluginCommand): void {
if (this.commands.has(command.name)) {
console.warn(`[PluginRegistry] Command /${command.name} already exists`);
return;
}
this.commands.set(command.name, { pluginId, command });
}
/** Remove a registered command by name. */
unregisterCommand(name: string): void {
this.commands.delete(name);
}
/** Execute a command by name, passing raw argument string. */
async executeCommand(name: string, rawArgs: string): Promise<string | void> {
const cmd = this.commands.get(name);
if (!cmd) throw new Error(`Command /${name} not found`);
const args = this.parseCommandArgs(cmd.command, rawArgs);
const entry = this.plugins.get(cmd.pluginId);
if (!entry) throw new Error(`Plugin ${cmd.pluginId} not loaded`);
return await cmd.command.run(args, entry.context);
}
private parseCommandArgs(command: PluginCommand, rawArgs: string): Record<string, unknown> {
if (!command.args || command.args.length === 0) return {};
const parts = rawArgs.trim().split(/\s+/);
const result: Record<string, unknown> = {};
command.args.forEach((arg, index) => {
const argName = typeof arg === 'string' ? arg : arg.name;
result[argName] = parts[index];
});
return result;
}
/** Return all registered commands. */
getCommands(): Array<{ name: string; pluginId: string; command: PluginCommand }> {
return Array.from(this.commands.entries()).map(([name, data]) => ({ name, ...data }));
}
// ---------------------------------------------------------------------------
// Message interceptors
// ---------------------------------------------------------------------------
/** Add a before-send interceptor for a plugin. */
addBeforeSendInterceptor(pluginId: string, interceptor: MessageInterceptor): void {
this.beforeSendInterceptors.push({ pluginId, interceptor });
}
/** Add a receive interceptor for a plugin. */
addReceiveInterceptor(pluginId: string, interceptor: MessageInterceptor): void {
this.receiveInterceptors.push({ pluginId, interceptor });
}
/** Run a message through all before-send interceptors in registration order. */
async processBeforeSend(msg: MessageContext): Promise<MessageContext> {
let processed = { ...msg };
for (const { interceptor } of this.beforeSendInterceptors) {
try {
await interceptor(processed);
} catch (err) {
console.error('[PluginRegistry] Error in beforeSend interceptor:', err);
}
}
return processed;
}
/** Run a message through all receive interceptors in registration order. */
async processReceive(msg: MessageContext): Promise<MessageContext> {
let processed = { ...msg };
for (const { interceptor } of this.receiveInterceptors) {
try {
await interceptor(processed);
} catch (err) {
console.error('[PluginRegistry] Error in receive interceptor:', err);
}
}
return processed;
}
// ---------------------------------------------------------------------------
// Renderers
// ---------------------------------------------------------------------------
/** Register a custom renderer contributed by a plugin. */
registerRenderer(pluginId: string, type: string, renderer: CustomRenderer): void {
this.renderers.set(type, { pluginId, renderer });
}
/** Remove a renderer by type. */
unregisterRenderer(type: string): void {
this.renderers.delete(type);
}
/** Get the renderer for a given type, if any. */
getRenderer(type: string): CustomRenderer | undefined {
return this.renderers.get(type)?.renderer;
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
/** Define the settings schema for a plugin and load persisted values. */
defineSettings(pluginId: string, schema: SettingsSchema): void {
this.settingsSchemas.set(pluginId, schema);
const stored = this.storage.getItem(`plugin:${pluginId}:settings`);
if (stored) {
try {
this.pluginSettings.set(pluginId, JSON.parse(stored));
} catch (err) {
console.error(`[PluginRegistry] Failed to load settings for ${pluginId}:`, err);
}
} else {
const defaults: Record<string, unknown> = {};
for (const [key, def] of Object.entries(schema)) {
if (def.default !== undefined) defaults[key] = def.default;
}
this.pluginSettings.set(pluginId, defaults);
}
}
/** Get a plugin setting value. */
getPluginSetting<T = unknown>(pluginId: string, key: string): T | undefined {
return this.pluginSettings.get(pluginId)?.[key] as T | undefined;
}
/** Set a plugin setting value and persist it. */
setPluginSetting(pluginId: string, key: string, value: unknown): void {
const settings = this.pluginSettings.get(pluginId) ?? {};
settings[key] = value;
this.pluginSettings.set(pluginId, settings);
this.storage.setItem(`plugin:${pluginId}:settings`, JSON.stringify(settings));
}
/** Get the settings schema for a plugin. */
getPluginSettingsSchema(pluginId: string): SettingsSchema | undefined {
return this.settingsSchemas.get(pluginId);
}
// ---------------------------------------------------------------------------
// Themes
// ---------------------------------------------------------------------------
/** Register a theme contributed by a plugin. */
registerTheme(pluginId: string, theme: PluginTheme): void {
const fullId = `plugin-${pluginId}-${theme.id}`;
if (this.themes.has(fullId)) {
console.warn(`[PluginRegistry] Theme ${fullId} already registered`);
return;
}
const css = generateThemeCSS(fullId, theme);
this.themes.set(fullId, { pluginId, theme: { ...theme, id: fullId } });
this.onThemeRegistered?.(fullId, fullId, css);
console.log(`[PluginRegistry] Theme registered: ${fullId}`);
}
/** Unregister a theme contributed by a plugin. */
unregisterTheme(pluginId: string, themeId: string): void {
const fullId = `plugin-${pluginId}-${themeId}`;
if (this.themes.has(fullId)) {
this.onThemeUnregistered?.(fullId);
this.themes.delete(fullId);
}
}
/** Return all registered plugin themes. */
getPluginThemes(): Array<{ id: string; name: string; kind: 'light' | 'dark'; className: string }> {
return Array.from(this.themes.values()).map(({ theme }) => ({
id: theme.id,
name: theme.name,
kind: theme.kind,
className: theme.id,
}));
}
// ---------------------------------------------------------------------------
// Event handlers (generic — wire to Matrix, socket.io, etc.)
// ---------------------------------------------------------------------------
/** Track an event handler registered by a plugin. */
addEventHandler(pluginId: string, eventType: string, handler: (...args: any[]) => void): void {
if (!this.matrixHandlers.has(eventType)) {
this.matrixHandlers.set(eventType, []);
}
this.matrixHandlers.get(eventType)!.push({ pluginId, handler });
}
/** Remove a previously tracked event handler. */
removeEventHandler(
pluginId: string,
eventType: string,
handler: (...args: any[]) => void
): void {
const handlers = this.matrixHandlers.get(eventType);
if (handlers) {
const filtered = handlers.filter(h => h.pluginId !== pluginId || h.handler !== handler);
this.matrixHandlers.set(eventType, filtered);
}
}
/** Get all tracked handlers for an event type. */
getEventHandlers(eventType: string): Array<{ pluginId: string; handler: (...args: any[]) => void }> {
return this.matrixHandlers.get(eventType) ?? [];
}
// ---------------------------------------------------------------------------
// Timers
// ---------------------------------------------------------------------------
/** Track a timer ID for a plugin so it can be cleared on unload. */
addTimer(pluginId: string, timerId: number): void {
const timers = this.timers.get(pluginId) ?? [];
timers.push(timerId);
this.timers.set(pluginId, timers);
}
/** Remove a timer ID from tracking (e.g. after manual clearInterval). */
removeTimer(pluginId: string, timerId: number): void {
const timers = this.timers.get(pluginId) ?? [];
this.timers.set(pluginId, timers.filter(id => id !== timerId));
}
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
/** Append a log entry from a plugin. */
addLog(pluginId: string, level: 'log' | 'warn' | 'error', args: unknown[]): void {
this.logs.push({ pluginId, level, timestamp: Date.now(), args });
if (this.logs.length > this.maxLogs) {
this.logs = this.logs.slice(-this.maxLogs);
}
}
/** Return log entries, optionally filtered to a specific plugin. */
getLogs(pluginId?: string): PluginLogEntry[] {
return pluginId ? this.logs.filter(l => l.pluginId === pluginId) : [...this.logs];
}
/** Clear log entries, optionally scoped to a specific plugin. */
clearLogs(pluginId?: string): void {
this.logs = pluginId ? this.logs.filter(l => l.pluginId !== pluginId) : [];
}
// ---------------------------------------------------------------------------
// Plugin exports (require())
// ---------------------------------------------------------------------------
/** Get the public exports of a loaded plugin. */
getPluginExports(pluginId: string): unknown {
return this.plugins.get(pluginId)?.plugin.exports;
}
// ---------------------------------------------------------------------------
// Hot reload
// ---------------------------------------------------------------------------
/** Unload the current plugin instance and load a new one in-place. */
async reloadPlugin(pluginId: string, newPlugin: Plugin, newContext: PluginContext): Promise<void> {
await this.unregisterPlugin(pluginId);
this.registerPlugin(pluginId, newPlugin, newContext);
await newPlugin.onLoad(newContext);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
/** Unload all plugins and clear all registry state. */
clear(): void {
for (const pluginId of this.plugins.keys()) {
this.unregisterPlugin(pluginId).catch(err => {
console.error(`[PluginRegistry] Error clearing plugin ${pluginId}:`, err);
});
}
this.plugins.clear();
this.commands.clear();
this.beforeSendInterceptors = [];
this.receiveInterceptors = [];
this.renderers.clear();
this.settingsSchemas.clear();
this.matrixHandlers.clear();
this.timers.clear();
this.logs = [];
}
}

40
src/index.ts Normal file
View File

@@ -0,0 +1,40 @@
export type {
IPluginStorage,
IPluginEventClient,
} from './interfaces.js';
export { MemoryStorage } from './interfaces.js';
export type {
PluginMetadata,
PluginIndex,
InstalledPlugin,
} from './types.js';
export { PluginTab } from './types.js';
export type {
ThemeColorGroup,
ThemePaletteGroup,
PluginThemeColors,
PluginTheme,
CommandArg,
PluginCommand,
MessageInterceptor,
MessageContext,
CustomRenderer,
SettingDefinition,
SettingsSchema,
NotificationOptions,
PluginContext,
PluginSettingsSection,
UILocation,
Plugin,
PluginLogEntry,
} from './PluginInterfaces.js';
export { generateThemeCSS } from './theme-css.js';
export { PluginRegistry } from './PluginRegistry.js';
export type { PluginRegistryOptions } from './PluginRegistry.js';
export { createPluginContext } from './PluginContext.js';
export type { PluginContextOptions } from './PluginContext.js';

34
src/interfaces.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Minimal key-value storage adapter.
* Pass `localStorage` in browser environments, or any Map-backed adapter for Node.js / tests.
*/
export interface IPluginStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
/**
* Minimal event emitter interface for the plugin events API.
* Compatible with Node.js EventEmitter, matrix-js-sdk MatrixClient, and similar clients.
*/
export interface IPluginEventClient {
on(event: string, handler: (...args: any[]) => void): void;
off(event: string, handler: (...args: any[]) => void): void;
}
/**
* In-memory storage implementation — used as the default when no storage adapter is provided.
*/
export class MemoryStorage implements IPluginStorage {
private readonly store = new Map<string, string>();
/** @inheritdoc */
getItem(key: string): string | null {
return this.store.get(key) ?? null;
}
/** @inheritdoc */
setItem(key: string, value: string): void {
this.store.set(key, value);
}
}

97
src/theme-css.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { PluginTheme } from './PluginInterfaces.js';
/**
* Generates a CSS block that overrides the folds vanilla-extract design tokens
* for a given theme class name.
*
* NOTE: The `--oq6d07*` variable names are stable compiled identifiers for the
* version of the `folds` design system used in Paarrot. They will need updating
* if folds is upgraded to a version that recompiles its theme contract.
*/
export function generateThemeCSS(className: string, theme: PluginTheme): string {
const {
background: bg,
surface: sf,
surfaceVariant: sv,
primary: pr,
secondary: sc,
success: su,
warning: wa,
critical: cr,
other: ot,
} = theme.colors;
const vars = `
.${className} {
--oq6d070: ${bg.container};
--oq6d071: ${bg.containerHover};
--oq6d072: ${bg.containerActive};
--oq6d073: ${bg.containerLine};
--oq6d074: ${bg.onContainer};
--oq6d075: ${sf.container};
--oq6d076: ${sf.containerHover};
--oq6d077: ${sf.containerActive};
--oq6d078: ${sf.containerLine};
--oq6d079: ${sf.onContainer};
--oq6d07a: ${sv.container};
--oq6d07b: ${sv.containerHover};
--oq6d07c: ${sv.containerActive};
--oq6d07d: ${sv.containerLine};
--oq6d07e: ${sv.onContainer};
--oq6d07f: ${pr.main};
--oq6d07g: ${pr.mainHover};
--oq6d07h: ${pr.mainActive};
--oq6d07i: ${pr.mainLine};
--oq6d07j: ${pr.onMain};
--oq6d07k: ${pr.container};
--oq6d07l: ${pr.containerHover};
--oq6d07m: ${pr.containerActive};
--oq6d07n: ${pr.containerLine};
--oq6d07o: ${pr.onContainer};
--oq6d07p: ${sc.main};
--oq6d07q: ${sc.mainHover};
--oq6d07r: ${sc.mainActive};
--oq6d07s: ${sc.mainLine};
--oq6d07t: ${sc.onMain};
--oq6d07u: ${sc.container};
--oq6d07v: ${sc.containerHover};
--oq6d07w: ${sc.containerActive};
--oq6d07x: ${sc.containerLine};
--oq6d07y: ${sc.onContainer};
--oq6d07z: ${su.main};
--oq6d0710: ${su.mainHover};
--oq6d0711: ${su.mainActive};
--oq6d0712: ${su.mainLine};
--oq6d0713: ${su.onMain};
--oq6d0714: ${su.container};
--oq6d0715: ${su.containerHover};
--oq6d0716: ${su.containerActive};
--oq6d0717: ${su.containerLine};
--oq6d0718: ${su.onContainer};
--oq6d0719: ${wa.main};
--oq6d071a: ${wa.mainHover};
--oq6d071b: ${wa.mainActive};
--oq6d071c: ${wa.mainLine};
--oq6d071d: ${wa.onMain};
--oq6d071e: ${wa.container};
--oq6d071f: ${wa.containerHover};
--oq6d071g: ${wa.containerActive};
--oq6d071h: ${wa.containerLine};
--oq6d071i: ${wa.onContainer};
--oq6d071j: ${cr.main};
--oq6d071k: ${cr.mainHover};
--oq6d071l: ${cr.mainActive};
--oq6d071m: ${cr.mainLine};
--oq6d071n: ${cr.onMain};
--oq6d071o: ${cr.container};
--oq6d071p: ${cr.containerHover};
--oq6d071q: ${cr.containerActive};
--oq6d071r: ${cr.containerLine};
--oq6d071s: ${cr.onContainer};
--oq6d071t: ${ot.focusRing};
--oq6d071u: ${ot.shadow};
--oq6d071v: ${ot.overlay};
}`;
return theme.extraCss ? `${vars}\n${theme.extraCss}` : vars;
}

39
src/types.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Plugin metadata as returned by the marketplace / plugin directory.
*/
export interface PluginMetadata {
id: string;
name: string;
version: string;
description: string;
author: string;
repository: string;
thumbnail: string;
downloadUrl: string;
homepage: string;
tags: string[];
addedDate: string;
}
/**
* Plugin directory index response from the marketplace.
*/
export interface PluginIndex {
version: string;
updatedAt: string;
plugins: string[];
}
/**
* An installed plugin — extends metadata with runtime state.
*/
export interface InstalledPlugin extends PluginMetadata {
installedDate: string;
enabled: boolean;
}
/** Tab identifiers for the plugin manager UI. */
export enum PluginTab {
Marketplace = 'marketplace',
Installed = 'installed',
}