feat(plugins): add export-format registration hook to plugin API

Add context.formats.registerExportFormat(id, opts) to PluginContext,
backed by a new FormatRegistry (mirrors PluginRegistry's Map-based
shape). Plugins register namespaced (${pluginId}:${id}) export
formats with a label/extension/handler; the writing-studio built-in
plugin registers a trivial "sprint-summary" .txt export as a
worked example.

The plugin system lives entirely in the renderer process while the
Export menu is built in main.js, so wiring formats into the menu
required a small IPC round-trip: renderer sends format metadata to
main after plugin load (main rebuilds the menu via the already-
idempotent createMenu()), and a menu click sends the resolved save
path back to the renderer, which is the only process holding the
plugin's handler function.

Amit Haridas
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent 44624cd4bf
commit d6baa2daf7
10 changed files with 304 additions and 3 deletions
+41
View File
@@ -1817,8 +1817,10 @@ document.addEventListener('DOMContentLoaded', async () => {
const { PluginRegistry } = require('./plugins/plugin-registry');
const { EventBus } = require('./plugins/event-bus');
const { SettingsStore } = require('./plugins/settings-store');
const { FormatRegistry } = require('./plugins/format-registry');
const pluginPath = require('path');
const pluginEventBus = new EventBus();
const pluginFormatRegistry = new FormatRegistry();
const pluginSettings = new SettingsStore({
get: (key) => window.electronAPI.invoke('plugin-settings:get', key),
set: (key, value) =>
@@ -1863,12 +1865,51 @@ document.addEventListener('DOMContentLoaded', async () => {
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
on: (ch, cb) => window.electronAPI.on(ch, cb),
},
formatRegistry: pluginFormatRegistry,
});
const builtInDir = pluginPath.join(__dirname, 'plugins', 'built-in');
const loader = new PluginLoader([builtInDir]);
const discovered = loader.discoverPlugins();
discovered.forEach((p) => pluginRegistry.register(p));
// Tell the main process which export formats plugins registered, so the
// Export menu (built in main.js, a separate process from this one) can
// grow entries for them. Only serializable metadata crosses the IPC
// boundary — the handler functions stay here in the renderer, since
// that's the only process that actually holds them.
ipcRenderer.send(
'plugin-export-formats-registered',
pluginFormatRegistry.getAll().map(({ id, label, extension }) => ({ id, label, extension }))
);
// Main process resolved a save path (via the Export menu) for a
// plugin-registered format; run the plugin's handler with the live
// editor content and report back so main can show success/error UI.
ipcRenderer.on('run-plugin-export-format', async (event, { id, outputPath } = {}) => {
const entry = pluginFormatRegistry.get(id);
if (!entry || typeof entry.handler !== 'function') {
ipcRenderer.send('plugin-export-format-result', {
id,
outputPath,
success: false,
error: `Export format "${id}" is not registered.`,
});
return;
}
try {
const content = tabManager.getCurrentContent();
await entry.handler(content, outputPath, {});
ipcRenderer.send('plugin-export-format-result', { id, outputPath, success: true });
} catch (err) {
ipcRenderer.send('plugin-export-format-result', {
id,
outputPath,
success: false,
error: err && err.message ? err.message : String(err),
});
}
});
// Initialize Zen Mode
const ZenModeClass = getZenMode();
const zenMode = new ZenModeClass(tabManager);