added examples

This commit is contained in:
2026-04-18 21:04:28 +10:00
parent 8e650e94b1
commit da18b7dfe5
12 changed files with 611 additions and 4 deletions

110
dist/PluginLoader.d.ts vendored Normal file
View File

@@ -0,0 +1,110 @@
import type { NotificationOptions } from './PluginInterfaces.js';
import type { IPluginEventClient } from './interfaces.js';
import type { PluginRegistry } from './PluginRegistry.js';
/**
* Configuration options for constructing a {@link PluginLoader}.
*
* @remarks Node.js only — this class uses `fs`, `path`, and `module` from the Node standard library.
*/
export interface PluginLoaderOptions {
/** The registry to register and wire loaded plugins into. */
registry: PluginRegistry;
/**
* Optional event client forwarded to each plugin's context.
* Any object with `on(event, handler)` / `off(event, handler)` works.
*/
eventClient?: IPluginEventClient;
/**
* Called when a plugin invokes `context.notify(...)`.
* The host is responsible for displaying the notification.
*/
onNotify?: (options: NotificationOptions) => void | Promise<void>;
}
/**
* Node.js-specific loader that discovers and registers plugins from the filesystem.
*
* Provide a directory; the loader scans each immediate subdirectory, resolves the
* entry point, imports the module (CJS or ESM), normalises it to the {@link Plugin}
* interface, and wires it into the supplied {@link PluginRegistry}.
*
* Supports:
* - Native plugins exporting `onLoad` / `onUnload`
* - Legacy plugins exporting `activate` / `deactivate` (with a compat shim)
* - `plugin-metadata.json` for stable plugin IDs
* - `package.json` `"main"` for custom entry points (falls back to `index.js`)
*
* @example
* ```ts
* const loader = new PluginLoader({ registry, eventClient: myEmitter });
* const ids = await loader.loadFromDirectory('/path/to/plugins');
* console.log('Loaded:', ids);
* ```
*/
export declare class PluginLoader {
private readonly registry;
private readonly eventClient?;
private readonly onNotify?;
constructor(options: PluginLoaderOptions);
/**
* Scans `dir` for plugin subdirectories and loads each one.
* Subdirectories that lack a recognisable entry point are silently skipped;
* those that throw during loading emit a console warning and are skipped.
*
* @param dir - Absolute path to the plugins folder.
* @returns IDs of all plugins that loaded successfully.
*/
loadFromDirectory(dir: string): Promise<string[]>;
/**
* Loads a single plugin from an absolute directory path.
*
* Resolution order for the plugin ID:
* 1. `plugin-metadata.json` → `id` field
* 2. `path.basename(pluginDir)`
*
* Resolution order for the entry point:
* 1. `package.json` → `main` field
* 2. `index.js`
*
* @param pluginDir - Absolute path to the plugin directory.
* @returns The plugin ID used for registration.
* @throws If no entry point is found or the module cannot be loaded.
*/
loadPlugin(pluginDir: string): Promise<string>;
/**
* Reads and parses `plugin-metadata.json` from the plugin directory.
* Returns `null` if the file is absent or malformed.
*
* @param pluginDir - Plugin root directory.
*/
private _readMetadata;
/**
* Resolves the JavaScript entry point for a plugin.
* Reads `main` from `package.json` when present; falls back to `index.js`.
*
* @param pluginDir - Plugin root directory.
* @throws If no entry file can be found.
*/
private _resolveEntry;
/**
* Imports a plugin module from an absolute file path.
*
* Attempts CJS evaluation first (via `new Function` with a `createRequire`-backed
* `require` shim). Falls back to native ESM `import()` on `SyntaxError`.
*
* @param filePath - Absolute path to the plugin entry file.
* @throws On load or evaluation errors that are not `SyntaxError`.
*/
private _importModule;
/**
* Normalises a raw plugin module export to the {@link Plugin} interface.
*
* - Native: object with `onLoad` function — used as-is.
* - Legacy: object with `activate` function — wrapped into `onLoad` / `onUnload`
* with a compatibility shim for removed APIs (`registerHook`, `runHook`, `getConfig`).
*
* @param raw - Raw module exports.
* @throws If the module exports neither `onLoad` nor `activate`.
*/
private _normalise;
}
//# sourceMappingURL=PluginLoader.d.ts.map

1
dist/PluginLoader.d.ts.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"PluginLoader.d.ts","sourceRoot":"","sources":["../src/PluginLoader.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAU,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAY1D;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,6DAA6D;IAC7D,QAAQ,EAAE,cAAc,CAAC;IAEzB;;;OAGG;IACH,WAAW,CAAC,EAAE,kBAAkB,CAAC;IAEjC;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnE;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiB;IAC1C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAqB;IAClD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAyD;gBAEvE,OAAO,EAAE,mBAAmB;IAUxC;;;;;;;OAOG;IACG,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAoBvD;;;;;;;;;;;;;;OAcG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA2BpD;;;;;OAKG;IACH,OAAO,CAAC,aAAa;IAYrB;;;;;;OAMG;IACH,OAAO,CAAC,aAAa;IAmBrB;;;;;;;;OAQG;YACW,aAAa;IA+B3B;;;;;;;;;OASG;IACH,OAAO,CAAC,UAAU;CA8BnB"}

200
dist/PluginLoader.js vendored Normal file
View File

@@ -0,0 +1,200 @@
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
import { createRequire } from 'module';
import { createPluginContext } from './PluginContext.js';
/**
* Node.js-specific loader that discovers and registers plugins from the filesystem.
*
* Provide a directory; the loader scans each immediate subdirectory, resolves the
* entry point, imports the module (CJS or ESM), normalises it to the {@link Plugin}
* interface, and wires it into the supplied {@link PluginRegistry}.
*
* Supports:
* - Native plugins exporting `onLoad` / `onUnload`
* - Legacy plugins exporting `activate` / `deactivate` (with a compat shim)
* - `plugin-metadata.json` for stable plugin IDs
* - `package.json` `"main"` for custom entry points (falls back to `index.js`)
*
* @example
* ```ts
* const loader = new PluginLoader({ registry, eventClient: myEmitter });
* const ids = await loader.loadFromDirectory('/path/to/plugins');
* console.log('Loaded:', ids);
* ```
*/
export class PluginLoader {
constructor(options) {
this.registry = options.registry;
this.eventClient = options.eventClient;
this.onNotify = options.onNotify;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Scans `dir` for plugin subdirectories and loads each one.
* Subdirectories that lack a recognisable entry point are silently skipped;
* those that throw during loading emit a console warning and are skipped.
*
* @param dir - Absolute path to the plugins folder.
* @returns IDs of all plugins that loaded successfully.
*/
async loadFromDirectory(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const loaded = [];
for (const entry of entries) {
if (!entry.isDirectory())
continue;
const pluginDir = path.join(dir, entry.name);
try {
const id = await this.loadPlugin(pluginDir);
loaded.push(id);
}
catch (err) {
console.warn(`[PluginLoader] Skipped "${entry.name}":`, err);
}
}
return loaded;
}
/**
* Loads a single plugin from an absolute directory path.
*
* Resolution order for the plugin ID:
* 1. `plugin-metadata.json` → `id` field
* 2. `path.basename(pluginDir)`
*
* Resolution order for the entry point:
* 1. `package.json` → `main` field
* 2. `index.js`
*
* @param pluginDir - Absolute path to the plugin directory.
* @returns The plugin ID used for registration.
* @throws If no entry point is found or the module cannot be loaded.
*/
async loadPlugin(pluginDir) {
const metadata = this._readMetadata(pluginDir);
const entryPath = this._resolveEntry(pluginDir);
const pluginId = metadata?.id ?? path.basename(pluginDir);
const raw = await this._importModule(entryPath);
const plugin = this._normalise(raw);
const context = createPluginContext({
pluginId,
eventClient: this.eventClient,
onNotify: this.onNotify,
}, this.registry);
this.registry.registerPlugin(pluginId, plugin, context);
await plugin.onLoad(context);
return pluginId;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/**
* Reads and parses `plugin-metadata.json` from the plugin directory.
* Returns `null` if the file is absent or malformed.
*
* @param pluginDir - Plugin root directory.
*/
_readMetadata(pluginDir) {
const metaPath = path.join(pluginDir, 'plugin-metadata.json');
if (!fs.existsSync(metaPath))
return null;
try {
return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
}
catch {
return null;
}
}
/**
* Resolves the JavaScript entry point for a plugin.
* Reads `main` from `package.json` when present; falls back to `index.js`.
*
* @param pluginDir - Plugin root directory.
* @throws If no entry file can be found.
*/
_resolveEntry(pluginDir) {
const pkgPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (pkg.main)
return path.resolve(pluginDir, pkg.main);
}
catch { /* fall through to index.js */ }
}
const indexPath = path.join(pluginDir, 'index.js');
if (!fs.existsSync(indexPath)) {
throw new Error(`No entry point found in "${pluginDir}"`);
}
return indexPath;
}
/**
* Imports a plugin module from an absolute file path.
*
* Attempts CJS evaluation first (via `new Function` with a `createRequire`-backed
* `require` shim). Falls back to native ESM `import()` on `SyntaxError`.
*
* @param filePath - Absolute path to the plugin entry file.
* @throws On load or evaluation errors that are not `SyntaxError`.
*/
async _importModule(filePath) {
const code = fs.readFileSync(filePath, 'utf8');
const pluginRequire = createRequire(pathToFileURL(filePath).href);
// CJS path — eval with a real require shim so the plugin can require deps
try {
const m = { exports: {} };
new Function('module', 'exports', '__dirname', '__filename', 'require', code)(m, m.exports, path.dirname(filePath), filePath, pluginRequire);
const result = m.exports?.['default'] ?? m.exports;
if (result && typeof result === 'object' && Object.keys(result).length > 0) {
return result;
}
}
catch (err) {
if (!(err instanceof SyntaxError))
throw err;
// SyntaxError means ESM syntax — fall through below
}
// ESM path — import via file URL
const mod = await import(pathToFileURL(filePath).href);
return (mod['default'] ?? mod);
}
/**
* Normalises a raw plugin module export to the {@link Plugin} interface.
*
* - Native: object with `onLoad` function — used as-is.
* - Legacy: object with `activate` function — wrapped into `onLoad` / `onUnload`
* with a compatibility shim for removed APIs (`registerHook`, `runHook`, `getConfig`).
*
* @param raw - Raw module exports.
* @throws If the module exports neither `onLoad` nor `activate`.
*/
_normalise(raw) {
if (typeof raw['onLoad'] === 'function')
return raw;
if (typeof raw['activate'] === 'function') {
const activate = raw['activate'];
const deactivate = raw['deactivate'];
return {
name: typeof raw['name'] === 'string' ? raw['name'] : undefined,
version: typeof raw['version'] === 'string' ? raw['version'] : undefined,
onLoad: async (ctx) => {
const legacyCtx = Object.assign(Object.create(ctx), {
registerHook: (name) => ctx.log(`[compat] registerHook("${name}") — not supported`),
runHook: (name) => {
ctx.log(`[compat] runHook("${name}")`);
return Promise.resolve([]);
},
getConfig: (key) => ctx.settings.get(key),
on: (event, handler) => ctx.events.on(event, handler),
});
await activate(legacyCtx);
},
onUnload: deactivate ? async () => { await deactivate(); } : undefined,
};
}
throw new Error('Plugin does not export `onLoad` or `activate` — cannot load');
}
}
//# sourceMappingURL=PluginLoader.js.map

1
dist/PluginLoader.js.map vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/index.d.ts vendored
View File

@@ -8,4 +8,6 @@ export { PluginRegistry } from './PluginRegistry.js';
export type { PluginRegistryOptions } from './PluginRegistry.js';
export { createPluginContext } from './PluginContext.js';
export type { PluginContextOptions } from './PluginContext.js';
export { PluginLoader } from './PluginLoader.js';
export type { PluginLoaderOptions } from './PluginLoader.js';
//# sourceMappingURL=index.d.ts.map

2
dist/index.d.ts.map vendored
View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,YAAY,EACV,cAAc,EACd,WAAW,EACX,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,qBAAqB,EACrB,UAAU,EACV,MAAM,EACN,cAAc,GACf,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,YAAY,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,YAAY,EACV,cAAc,EACd,WAAW,EACX,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,qBAAqB,EACrB,UAAU,EACV,MAAM,EACN,cAAc,GACf,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,YAAY,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAE/D,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}

1
dist/index.js vendored
View File

@@ -3,4 +3,5 @@ export { PluginTab } from './types.js';
export { generateThemeCSS } from './theme-css.js';
export { PluginRegistry } from './PluginRegistry.js';
export { createPluginContext } from './PluginContext.js';
export { PluginLoader } from './PluginLoader.js';
//# sourceMappingURL=index.js.map

2
dist/index.js.map vendored
View File

@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAOhD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAsBvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAOhD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAsBvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAGzD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}