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
+60
View File
@@ -419,6 +419,7 @@ let pandocVersionCache = null; // Cached parsed { major, minor } from `pandoc --
let wordTemplatePath = null; // Path to selected Word template
let templateStartPage = 3; // Which page to start inserting content (default: page 3)
let rendererReady = false; // Track if renderer is ready to receive file data
let pluginExportFormats = []; // Export formats registered by plugins: [{ id, label, extension }]
// Header & Footer Settings
let headerFooterSettings = {
@@ -997,6 +998,7 @@ function createMenu() {
label: 'Jupyter Notebook (.ipynb)',
click: () => exportFile('ipynb'),
},
...buildPluginExportMenuItems(),
],
},
{
@@ -1895,6 +1897,42 @@ function exportFile(format) {
function showExportOptionsDialog(format) {
mainWindow.webContents.send('show-export-dialog', format);
}
// Build the dynamic tail of the Export submenu from plugin-registered
// formats (see plugin-export-formats-registered IPC handler below). Returns
// [] when no plugin has registered a format, so the Export menu is
// unchanged for a plain install.
function buildPluginExportMenuItems() {
if (!pluginExportFormats.length) return [];
const items = pluginExportFormats.map((fmt) => ({
label: fmt.label || fmt.id,
click: () => runPluginExportFormat(fmt),
}));
return [{ type: 'separator' }, ...items];
}
// Resolve an output path for a plugin-registered export format (main
// process owns save dialogs, same as every other export path in this
// file), then hand off to the renderer — the plugin's handler function
// only exists there, since that's the process that loaded the plugin.
function runPluginExportFormat(fmt) {
if (!currentFile) {
dialog.showErrorBox('Error', 'Please save the file first');
return;
}
const ext = fmt.extension || 'txt';
const outputFile = dialog.showSaveDialogSync(mainWindow, {
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${ext}`),
filters: [
{
name: fmt.label || fmt.id,
extensions: [ext],
},
],
});
if (!outputFile) return; // User cancelled
mainWindow.webContents.send('run-plugin-export-format', { id: fmt.id, outputPath: outputFile });
}
function showBatchConversionDialog() {
mainWindow.webContents.send('show-batch-dialog');
}
@@ -4637,6 +4675,28 @@ ipcMain.on('clear-recent-files', (event) => {
}
});
// Plugins (loaded in the renderer) report the export formats they've
// registered; rebuild the Export menu so they show up as entries.
// createMenu() is idempotent and already re-invoked elsewhere (e.g. after
// clearRecentFilesOnDisk() above) so calling it again here is safe.
ipcMain.on('plugin-export-formats-registered', (event, formats) => {
pluginExportFormats = Array.isArray(formats)
? formats.filter((f) => f && typeof f.id === 'string')
: [];
createMenu();
});
// Result of a plugin export handler running in the renderer (see
// runPluginExportFormat() / the Export menu wiring above).
ipcMain.on('plugin-export-format-result', (event, result) => {
const { outputPath, success, error } = result || {};
if (success) {
showExportSuccess(outputPath);
} else {
dialog.showErrorBox('Export Error', sanitizeErrorMessage(error || 'Unknown error'));
}
});
// Handle file opening on macOS
app.on('open-file', (event, filePath) => {
event.preventDefault();
@@ -29,6 +29,7 @@ class WritingStudioPlugin extends PluginAPI {
this._registerCommands(context);
this._registerStatusBar(context);
this._registerExportFormats(context);
}
_registerCommands(context) {
@@ -120,6 +121,42 @@ class WritingStudioPlugin extends PluginAPI {
});
}
// Example usage of context.formats.registerExportFormat (Task 17): adds
// a "Writing Studio Summary" entry to the Export menu that writes a
// plain-text snapshot of today's sprint/goal progress instead of going
// through Pandoc. Doubles as documentation for how a plugin can offer
// its own export target.
_registerExportFormats(context) {
const { sprintEngine, goalTracker } = this;
context.formats.registerExportFormat('sprint-summary', {
label: 'Writing Studio Summary (.txt)',
extension: 'txt',
handler: async (markdownContent, outputPath) => {
const fs = require('fs');
const goal = context.settings.get('dailyGoal') || 1000;
const progress = goalTracker.getDailyProgress(goal);
const streak = goalTracker.getStreak(goal);
const wordCount = (markdownContent || '').split(/\s+/).filter(Boolean).length;
const lines = [
'Writing Studio Summary',
'=======================',
`Generated: ${new Date().toISOString()}`,
'',
`Document word count: ${wordCount}`,
`Daily goal: ${goal}`,
`Words written today: ${progress.written} (${progress.pct}%)`,
`Current streak: ${streak} day(s)`,
`Sprint active: ${sprintEngine.isActive() ? 'yes' : 'no'}`,
'',
];
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8');
},
});
}
deactivate() {
if (this._sprintInterval) clearInterval(this._sprintInterval);
}
@@ -32,7 +32,14 @@
"shortcut": "Ctrl+Alt+G"
}
],
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
"statusBar": { "indicators": ["sprint-timer", "word-goal"] },
"exportFormats": [
{
"id": "sprint-summary",
"label": "Writing Studio Summary (.txt)",
"extension": "txt"
}
]
},
"settings": [
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
+40
View File
@@ -0,0 +1,40 @@
/**
* FormatRegistry — tracks export formats registered by plugins via
* `context.formats.registerExportFormat(id, opts)`.
*
* Mirrors the simple Map-backed storage pattern used by PluginRegistry
* (see plugin-registry.js), scoped to a single concern: export format
* metadata + handler functions rather than whole plugin instances.
*/
class FormatRegistry {
constructor() {
this.formats = new Map();
}
/**
* Register (or overwrite) an export format entry.
* @param {string} id - Fully-namespaced format id, e.g. "writing-studio:sprint-summary"
* @param {object} opts - { label, extension, handler: async (markdownContent, outputPath, options) => void }
*/
register(id, opts) {
this.formats.set(id, opts);
}
/**
* Look up a single registered format entry by its namespaced id.
* @param {string} id
* @returns {object|undefined}
*/
get(id) {
return this.formats.get(id);
}
/**
* Return all registered formats as an array of { id, ...opts }.
*/
getAll() {
return Array.from(this.formats.entries()).map(([id, opts]) => ({ id, ...opts }));
}
}
module.exports = { FormatRegistry };
+26 -2
View File
@@ -10,10 +10,21 @@ class PluginContext {
* @param {object} deps.editor - { getContent, getSelection, insertAtCursor, onContentChanged }
* @param {object} deps.ipc - { invoke, on }
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
* @param {object} deps.formatRegistry - FormatRegistry instance ({ register, get, getAll })
*/
constructor(deps) {
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } =
deps;
const {
pluginId,
sidebar,
commands,
statusBar,
eventBus,
settings,
editor,
ipc,
exportHooks,
formatRegistry,
} = deps;
this.sidebar = {
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
@@ -69,6 +80,19 @@ class PluginContext {
if (exportHooks) exportHooks.postHooks.push(handler);
},
};
this.formats = {
/**
* Register a plugin-provided export format. It is namespaced as
* `${pluginId}:${id}` so plugins can't collide with each other or
* with the built-in Pandoc-backed formats.
* @param {string} id - Format id, unique within this plugin.
* @param {object} opts - { label, extension, handler: async (markdownContent, outputPath, options) => void }
*/
registerExportFormat: (id, opts) => {
if (formatRegistry) formatRegistry.register(`${pluginId}:${id}`, opts);
},
};
}
}
+1
View File
@@ -33,6 +33,7 @@ class PluginRegistry {
editor: this.deps.editor,
ipc: this.deps.ipc,
exportHooks: this.exportHooks,
formatRegistry: this.deps.formatRegistry,
});
try {
+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);
+39
View File
@@ -0,0 +1,39 @@
const { FormatRegistry } = require('../src/plugins/format-registry');
describe('FormatRegistry', () => {
let registry;
beforeEach(() => {
registry = new FormatRegistry();
});
test('register — stores an entry retrievable by id', () => {
const handler = jest.fn();
registry.register('plugin-a:fmt', { label: 'Format A', extension: 'txt', handler });
const entry = registry.get('plugin-a:fmt');
expect(entry).toEqual({ label: 'Format A', extension: 'txt', handler });
});
test('get — returns undefined for unknown id', () => {
expect(registry.get('nope')).toBeUndefined();
});
test('register — overwrites an existing id', () => {
registry.register('plugin-a:fmt', { label: 'First' });
registry.register('plugin-a:fmt', { label: 'Second' });
expect(registry.get('plugin-a:fmt').label).toBe('Second');
});
test('getAll — returns all entries with id merged in', () => {
registry.register('plugin-a:fmt', { label: 'Format A', extension: 'txt' });
registry.register('plugin-b:fmt', { label: 'Format B', extension: 'csv' });
const all = registry.getAll();
expect(all).toHaveLength(2);
expect(all).toContainEqual({ id: 'plugin-a:fmt', label: 'Format A', extension: 'txt' });
expect(all).toContainEqual({ id: 'plugin-b:fmt', label: 'Format B', extension: 'csv' });
});
test('getAll — returns empty array when nothing registered', () => {
expect(registry.getAll()).toEqual([]);
});
});
+15
View File
@@ -21,6 +21,7 @@ describe('PluginContext', () => {
},
ipc: { invoke: jest.fn(), on: jest.fn() },
exportHooks: { preHooks: [], postHooks: [] },
formatRegistry: { register: jest.fn(), get: jest.fn(), getAll: jest.fn() },
};
context = new PluginContext(mockDeps);
});
@@ -87,4 +88,18 @@ describe('PluginContext', () => {
context.exports.registerPostHook(handler);
expect(mockDeps.exportHooks.postHooks).toContain(handler);
});
test('exposes formats.registerExportFormat with namespaced id', () => {
const handler = jest.fn();
const opts = { label: 'My Format', extension: 'txt', handler };
context.formats.registerExportFormat('my-format', opts);
expect(mockDeps.formatRegistry.register).toHaveBeenCalledWith('test-plugin:my-format', opts);
});
test('formats.registerExportFormat is a no-op when no formatRegistry is injected', () => {
const noRegistryContext = new PluginContext({ ...mockDeps, formatRegistry: undefined });
expect(() =>
noRegistryContext.formats.registerExportFormat('x', { handler: jest.fn() })
).not.toThrow();
});
});
+37
View File
@@ -1,6 +1,7 @@
const { PluginRegistry } = require('../src/plugins/plugin-registry');
const { PluginAPI } = require('../src/plugins/plugin-api');
const { EventBus } = require('../src/plugins/event-bus');
const { FormatRegistry } = require('../src/plugins/format-registry');
class TestPlugin extends PluginAPI {
init(context) {
@@ -153,4 +154,40 @@ describe('PluginRegistry', () => {
registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler);
expect(registry.exportHooks.preHooks).toContain(handler);
});
test('a plugin calling context.formats.registerExportFormat populates the injected FormatRegistry with a namespaced entry', () => {
const formatRegistry = new FormatRegistry();
const registryWithFormats = new PluginRegistry({ ...mockDeps, formatRegistry });
class ExportingPlugin extends PluginAPI {
init(context) {
context.formats.registerExportFormat('sprint-summary', {
label: 'Writing Studio Summary (.txt)',
extension: 'txt',
handler: async () => {},
});
}
}
registryWithFormats.register({
id: 'writing-studio',
name: 'Writing Studio',
version: '1.0.0',
description: 'desc',
manifest: {},
PluginClass: ExportingPlugin,
dir: '/tmp/test',
});
const entry = formatRegistry.get('writing-studio:sprint-summary');
expect(entry).toBeDefined();
expect(entry.label).toBe('Writing Studio Summary (.txt)');
expect(entry.extension).toBe('txt');
expect(typeof entry.handler).toBe('function');
const all = formatRegistry.getAll();
expect(all).toContainEqual(
expect.objectContaining({ id: 'writing-studio:sprint-summary', extension: 'txt' })
);
});
});