This document describes how to develop plugins for flyMD
- Overview
- Quick Start
- Plugin Structure
- Plugin API
- Lifecycle
- Example Plugins
- Publishing Plugins
- Theme Extensions
flyMD provides a flexible plugin system that allows developers to extend the editor's functionality. Plugins can:
- Add custom menu items
- Access and modify editor content
- Call Tauri backend commands
- Use HTTP client for network requests
- Store plugin-specific configuration data
- Display notifications and confirmation dialogs
flyMD includes the following built-in extensions:
- Image Hosting (S3/R2) - Upload images to S3/R2 object storage
- WebDAV Sync - Sync documents via WebDAV protocol
- Typecho Publisher - Publish articles to Typecho blog platform (optional)
Create a new directory with the following files:
my-plugin/
├── manifest.json # Plugin manifest file
└── main.js # Plugin main file
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "Plugin functionality description",
// Optional: i18n metadata (recommended)
"i18n": {
"zh": {
"name": "我的插件",
"description": "插件功能描述"
}
},
"main": "main.js"
}Field Descriptions:
id(required): Unique plugin identifier, use lowercase letters and hyphensname(required): Plugin display nameversion(required): Plugin version number, semantic versioning recommendedauthor(optional): Author informationdescription(optional): Plugin functionality descriptionmain(required): Plugin entry file, defaults tomain.jsminHostVersion(optional): Minimum required flyMD version. Installation will be rejected if user's flyMD version is lower, prompting them to upgradei18n(optional, recommended): Multi-language metadata organized by language code, e.g.:i18n.en.name/i18n.en.description: English name and description- Current host versions do not rely on this field yet, but future versions may prefer it when rendering the marketplace in different languages. Pre-populating it makes your plugin more future-proof.
// main.js
export function activate(context) {
// Executed when plugin is activated
context.ui.notice('My plugin activated!', 'ok', 2000);
// Add menu item
context.addMenuItem({
label: 'My Plugin',
title: 'Click to execute plugin functionality',
onClick: async () => {
const content = context.getEditorValue();
context.ui.notice('Current content length: ' + content.length, 'ok');
}
});
}
export function deactivate() {
// Executed when plugin is deactivated (optional)
console.log('Plugin deactivated');
}
export function openSettings(context) {
// Open plugin settings interface (optional)
context.ui.notice('Open settings interface', 'ok');
}- Create a GitHub repository
- Push
manifest.jsonandmain.jsto the repository - Users can install via
username/repoorusername/repo@branchformat
In flyMD:
- Click the "Extensions" button in the menu bar
- Enter in the extension installation input box:
- GitHub repository:
username/repositoryorusername/repository@branch - HTTP URL:
https://example.com/path/to/manifest.json
- GitHub repository:
- Click the "Install" button
my-plugin/
├── manifest.json # Plugin manifest (required)
├── main.js # Plugin main file (required)
├── README.md # Documentation (recommended)
└── assets/ # Resource files (optional)
└── icon.png
{
"id": "example-plugin",
"name": "Example Plugin",
"version": "1.0.0",
"author": "Your Name <email@example.com>",
"description": "This is an example plugin demonstrating flyMD extension development",
"main": "main.js",
"minHostVersion": "0.3.0",
"homepage": "https://github.com/username/example-plugin",
"repository": "https://github.com/username/example-plugin"
}Version Compatibility Example:
If your plugin uses new APIs introduced in flyMD 0.3.5, you can set:
{
"id": "my-advanced-plugin",
"name": "Advanced Features Plugin",
"version": "2.0.0",
"minHostVersion": "0.3.5",
"description": "This plugin requires flyMD 0.3.5 or higher"
}When users try to install this plugin on flyMD 0.3.4 or lower, they will receive an error message:
This extension requires flyMD 0.3.5 or higher, current version is 0.3.4.
Please upgrade flyMD before installing this extension.
Plugins access flyMD functionality through the context object.
HTTP client for network requests.
// GET request
const response = await context.http.fetch('https://api.example.com/data', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
// POST request
const response = await context.http.fetch('https://api.example.com/post', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key: 'value' })
});Read YAML Front Matter at the beginning of the current document and its parsed metadata, useful for blog publishing, library enhancements, and external app sync.
Detection rules:
- Front Matter is recognized only when the top of the document matches:
- First line is
---- There is at least one line that looks like
key: valuein between- A separate
---line terminates the block- Otherwise these helpers treat the document as plain Markdown and never modify file content
// 1. Raw Front Matter text (including --- delimiters), or null when missing
const raw = context.getFrontMatterRaw();
// Example:
// ---
// title: "This is the title"
// keywords: [markdown, hexo]
// ---\n
// 2. Parsed metadata object (using js-yaml); null if missing or parse failed
const meta = context.getDocMeta();
// Typical structure:
// {
// title: "This is the title",
// keywords: ["markdown", "hexo"],
// author: ["Author One", "Author Two"],
// abstract: "This is the abstract."
// }
// 3. Body part (Markdown without Front Matter)
const body = context.getDocBody();
// - With Front Matter: body starts at the first real content line
// - Without Front Matter: equals context.getEditorValue()Example: publish using Front Matter title and tags
export function activate(context) {
context.addMenuItem({
label: 'Publish to blog',
async onClick() {
const meta = context.getDocMeta() || {};
const body = context.getDocBody();
const title = meta.title || guessTitleFromBody(body);
const tags = meta.tags || meta.keywords || [];
await publishToBlog({
title,
tags,
content: body,
excerpt: meta.abstract || ''
});
context.ui.notice('Published: ' + title, 'ok');
}
});
}
function guessTitleFromBody(body) {
const m = body.match(/^#\s+(.+)$/m);
return (m && m[1]) || 'Untitled';
}Call Tauri backend commands.
// Call backend command
try {
const result = await context.invoke('command_name', {
param1: 'value1',
param2: 'value2'
});
console.log('Command execution result:', result);
} catch (error) {
console.error('Command execution failed:', error);
}Plugin-specific storage space.
// Save data
await context.storage.set('key', { name: 'value', count: 42 });
// Read data
const data = await context.storage.get('key');
console.log(data); // { name: 'value', count: 42 }
// Delete data (set to null)
await context.storage.set('key', null);Add custom menu items to the menu bar, supporting simple menu items and dropdown menus.
const removeMenuItem = context.addMenuItem({
label: 'Menu Text',
title: 'Mouse hover tooltip',
onClick: () => {
// Action on click
context.ui.notice('Menu clicked!');
}
});
// Remove menu item (optional)
// removeMenuItem();Use the children parameter to create dropdown menus:
context.addMenuItem({
label: 'My Tools',
title: 'Tools menu',
children: [
{
label: 'Option 1',
onClick: () => {
context.ui.notice('Option 1 clicked');
}
},
{
label: 'Option 2',
onClick: () => {
context.ui.notice('Option 2 clicked');
}
}
]
});context.addMenuItem({
label: 'To-Do',
children: [
// Group title
{
type: 'group',
label: 'Push'
},
{
label: 'All',
note: 'Completed/Incomplete', // Right-side note
onClick: () => pushAll()
},
{
label: 'Completed',
onClick: () => pushDone()
},
{
label: 'Incomplete',
onClick: () => pushTodo()
},
// Divider
{
type: 'divider'
},
{
type: 'group',
label: 'Reminders'
},
{
label: 'Create Reminder',
note: '@time',
onClick: () => createReminder()
},
// Disabled state
{
label: 'Advanced Features',
disabled: true,
note: 'Coming soon'
}
]
});Regular menu item:
label: Menu text (required)onClick: Click callback function (required)note: Right-side note text (optional)disabled: Whether disabled (optional, defaults tofalse)
Group title:
{
type: 'group',
label: 'Group Name'
}Divider:
{
type: 'divider'
}Notes:
- Each plugin can only add one menu item
- If
childrenis provided,onClickis not needed - Dropdown menu automatically positions to avoid viewport overflow
- Supports ESC key to close dropdown
- Clicking outside area closes dropdown
Register context menu items in the editor, supporting context awareness and conditional display.
// Register a simple context menu item
const removeItem = context.addContextMenuItem({
label: 'Convert to Uppercase',
icon: '🔤',
condition: (ctx) => ctx.selectedText.length > 0, // Only show when text is selected
onClick: (ctx) => {
const upperText = ctx.selectedText.toUpperCase();
context.replaceRange(
context.getSelection().start,
context.getSelection().end,
upperText
);
context.ui.notice('Converted to uppercase', 'ok');
}
});
// Remove menu item (optional)
// removeItem();context.addContextMenuItem({
label: 'Text Tools',
icon: '🛠️',
children: [
{
label: 'To Uppercase',
onClick: (ctx) => {
const upper = ctx.selectedText.toUpperCase();
context.replaceRange(
context.getSelection().start,
context.getSelection().end,
upper
);
}
},
{
label: 'To Lowercase',
onClick: (ctx) => {
const lower = ctx.selectedText.toLowerCase();
context.replaceRange(
context.getSelection().start,
context.getSelection().end,
lower
);
}
},
{ type: 'divider' }, // Divider
{
label: 'Remove Spaces',
onClick: (ctx) => {
const trimmed = ctx.selectedText.replace(/\s+/g, '');
context.replaceRange(
context.getSelection().start,
context.getSelection().end,
trimmed
);
}
}
]
});context.addContextMenuItem({
label: 'Advanced Editing',
icon: '✨',
children: [
// Group title
{
type: 'group',
label: 'Format Conversion'
},
{
label: 'Camel Case',
note: 'camelCase',
condition: (ctx) => ctx.selectedText.length > 0,
onClick: (ctx) => {
const camelCase = ctx.selectedText
.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : '');
context.replaceRange(
context.getSelection().start,
context.getSelection().end,
camelCase
);
}
},
{
label: 'Snake Case',
note: 'snake_case',
condition: (ctx) => ctx.selectedText.length > 0,
onClick: (ctx) => {
const snakeCase = ctx.selectedText
.replace(/([A-Z])/g, '_$1')
.replace(/[-\s]+/g, '_')
.toLowerCase()
.replace(/^_/, '');
context.replaceRange(
context.getSelection().start,
context.getSelection().end,
snakeCase
);
}
},
{ type: 'divider' },
{
type: 'group',
label: 'Insert'
},
{
label: 'Insert Timestamp',
onClick: (ctx) => {
const timestamp = new Date().toISOString();
context.insertAtCursor(timestamp);
}
},
// Disabled state
{
label: 'AI Polish',
disabled: true,
note: 'Coming soon'
}
]
});The condition and onClick callback functions receive a context object:
{
selectedText: string, // Currently selected text
cursorPosition: number, // Cursor position
mode: 'edit' | 'preview' | 'wysiwyg', // Current editing mode
filePath: string | null // Current file path
}Regular menu item:
label: Menu text (required)icon: Icon, supports emoji (optional)onClick: Click callback function, receives context object (required)condition: Condition function, shows when returnstrue(optional)note: Right-side note text (optional)disabled: Whether disabled (optional, defaults tofalse)
With submenus:
label: Menu text (required)icon: Icon (optional)children: Array of submenu items (required)
Group title:
{
type: 'group',
label: 'Group Name'
}Divider:
{
type: 'divider'
}- Context menu automatically adjusts position based on viewport boundaries to prevent overflow
- Submenu intelligent positioning: automatically detects available space, expands right or left to ensure visibility
- Supports ESC key to close menu
- Clicking outside area closes menu
conditionfunction dynamically controls menu item visibility- Each extension can register multiple context menu items
- Context menu only overrides browser default menu when extensions are registered
- Access native context menu: Hold
Shiftkey while right-clicking to show browser native menu - Submenus support hover expansion, mouse over menu items with arrows to expand submenus
- Language switching: flyMD can switch UI language at runtime; built-in menus refresh automatically, but plugin-registered context menu items do not (the
labeltext is fixed when you calladdContextMenuItem). To make your menu text follow language changes immediately, listen for the language change event and re-register menus.
When the user changes language via the menu, the host dispatches a global event:
window.addEventListener('flymd:localeChanged', (ev) => {
// ev.detail.pref is 'auto' | 'zh' | 'en'
});If your context menu needs to switch language immediately (instead of waiting for an app restart), you can:
- In
activate, wrap “register menu” into a helper function; - Store the disposer returned by
context.addContextMenuItem; - On
flymd:localeChanged, remove the old menu and re-register with the new language; - Clean up the event listener and menu items in
deactivate(or your own cleanup hook).
Example (simplified):
let disposeMenu = null;
function getLabel(pref) {
if (pref === 'en') return 'Format Code';
return '格式化代码';
}
export function activate(context) {
function registerMenu(pref) {
if (disposeMenu) { disposeMenu(); disposeMenu = null; }
disposeMenu = context.addContextMenuItem({
label: getLabel(pref),
icon: '🎨',
condition: (ctx) => ctx.mode === 'edit' && ctx.selectedText.length > 0,
onClick: (ctx) => { /* ... */ }
});
}
// Read current preference (localStorage: flymd.locale), default to 'auto' when not set
const pref = localStorage.getItem('flymd.locale') || 'auto';
registerMenu(pref);
const onLocaleChanged = (ev) => {
const nextPref = ev.detail?.pref || 'auto';
registerMenu(nextPref);
};
window.addEventListener('flymd:localeChanged', onLocaleChanged);
// Simple cleanup: remove listener and menu on deactivate
context.onDeactivate = () => {
window.removeEventListener('flymd:localeChanged', onLocaleChanged);
if (disposeMenu) disposeMenu();
};
}Note:
flymd:localeChangedonly fires when the user explicitly changes language from the menu.detail.prefis the preference (auto/ force Chinese / force English); the actual UI language is resolved by the host.
// Code formatting tool
export function activate(context) {
context.addContextMenuItem({
label: 'Format Code',
icon: '🎨',
condition: (ctx) => {
// Only show in edit mode when text is selected
return ctx.mode === 'edit' && ctx.selectedText.length > 0;
},
onClick: (ctx) => {
try {
// Try to format JSON
const formatted = JSON.stringify(JSON.parse(ctx.selectedText), null, 2);
context.replaceRange(
context.getSelection().start,
context.getSelection().end,
formatted
);
context.ui.notice('JSON formatted successfully', 'ok');
} catch {
context.ui.notice('Formatting failed, please check JSON syntax', 'err');
}
}
});
}Display notification messages.
// Show success notification (default)
context.ui.notice('Operation successful!', 'ok', 2000);
// Show error notification
context.ui.notice('Operation failed!', 'err', 3000);
// Parameter descriptions:
// - message: Notification content
// - level: 'ok' or 'err', defaults to 'ok'
// - ms: Display duration (milliseconds), defaults to 1600Display confirmation dialog.
const confirmed = await context.ui.confirm('Are you sure you want to perform this operation?');
if (confirmed) {
context.ui.notice('User confirmed operation');
} else {
context.ui.notice('User canceled operation');
}Get current editor content.
const content = context.getEditorValue();
console.log('Current content:', content);
console.log('Character count:', content.length);Set editor content.
// Replace all content
context.setEditorValue('# New Content\n\nThis is new content');
// Append content
const current = context.getEditorValue();
context.setEditorValue(current + '\n\nAppended content');Note: Calling this method will:
- Mark document as unsaved
- Update title bar and status bar
- Auto re-render preview if in preview mode
Open file selection dialog in desktop version, select one or more Markdown documents (md / markdown / txt), return absolute path array.
// Select multiple documents
const files = await context.pickDocFiles({ multiple: true });
if (!files || files.length === 0) {
context.ui.notice('No documents selected', 'err');
} else {
context.ui.notice('Selected ' + files.length + ' documents', 'ok');
}Note:
- Only available in desktop version (Tauri app), browser environment returns empty array with alert.
- Return value is string array, each item is absolute file path.
Get the absolute path of the current document. Returns null if the content has not been saved to disk yet or the runtime does not expose a file path.
const path = context.getCurrentFilePath();
if (!path) {
context.ui.notice('Current document is not saved yet, no path available', 'err');
} else {
context.ui.notice('Current file path: ' + path, 'ok');
}Typical usage:
- Combine with
context.readFileBinaryto read raw bytes of the current PDF/image for upload or parsing; - Use as the source path when calling
context.createStickyNoteorcontext.openFileByPath.
Notes:
- Returns OS-level absolute paths (e.g.
C:/docs/note.mdor/home/user/note.md); - For “new, unsaved” documents this will be
null.
Get the absolute path of the currently active library root directory. Returns null if no library is open.
const root = await context.getLibraryRoot();
if (!root) {
context.ui.notice('No library is currently open', 'err');
} else {
context.ui.notice('Current library root: ' + root, 'ok');
}Typical usage:
- Combine with
context.getCurrentFilePathto calculate “current file’s folder”; - Build library-relative paths when exporting or generating helper documents.
Watch filesystem changes under the current library root (optionally recursive). Returns an unwatch() function.
Event object:
type:'create' | 'modify' | 'remove' | 'access' | 'any' | 'other'kind: more detailed kind string from the underlying watcherpaths: absolute paths reported by the watcherrelatives: paths relative to the library root (empty string if not under the library)libraryRoot: library root absolute pathraw: raw watcher event
const unwatch = await context.watchLibrary((ev) => {
if (ev.type !== 'create') return;
console.log('New files:', ev.relatives.filter(Boolean));
}, { recursive: true, immediate: true });
// unwatch();Watch specific paths (files or directories). By default, non-absolute paths are resolved relative to the current library root.
const unwatch = await context.watchPaths(
['Notes/', 'Essays/'],
(ev) => {
if (ev.type !== 'create') return;
console.log(ev.relatives.filter(Boolean));
},
{ base: 'library', recursive: true, immediate: true }
);Read local file as binary content by absolute path and return a Uint8Array. Useful for PDF parsing, image processing and other binary workflows.
const path = context.getCurrentFilePath();
if (!path) {
context.ui.notice('Current document is not saved yet, cannot read file bytes', 'err');
return;
}
// Read raw bytes (for example, to upload to a remote API)
const bytes = await context.readFileBinary(path);
context.ui.notice('Read bytes: ' + bytes.length, 'ok');Notes:
- Only available in desktop version (Tauri app), relies on underlying filesystem permissions;
- Argument must be an absolute path; passing an empty or invalid path will throw an error;
- Return value is always a
Uint8Array, convenient for passing toFormData,fetchand similar APIs.
Append text to the end of a local file (does not overwrite existing content). Useful for “history logs” (e.g. incremental indexing logs).
await context.appendTextFile('C:/tmp/my.log', 'one log line\\n');Save a piece of Markdown content directly as a file in the library, by default using the folder of the “current document” in the sidebar. If there is no current document, it falls back to the library root. This is useful for automatically generating .md files after parsing/translating PDFs.
const fileName = 'example.pdf.md';
const content = '# Markdown parsed from PDF...';
try {
const savedPath = await context.saveMarkdownToCurrentFolder({
fileName,
content,
onConflict: 'renameAuto', // 'overwrite' | 'renameAuto' | 'error'
});
context.ui.notice('Saved to: ' + savedPath, 'ok');
} catch (e) {
context.ui.notice('Save failed: ' + (e?.message || String(e)), 'err');
}Parameters:
fileName: target file name (no path), e.g.document.pdf.mdordocument.translated.md;content: Markdown text to write;onConflict(optional): how to handle name conflicts:'overwrite': overwrite existing file;'renameAuto'(default): automatically append-1,-2, etc. to avoid conflicts;'error': throw if the file already exists.
Behavior:
- Prefers the folder of the currently open document as target directory;
- If there is no current file or it is outside the active library, falls back to the library root;
- The implementation ensures the final path stays inside the current library.
Safely save binary data (Uint8Array / ArrayBuffer / number[]) into the folder of the current document (or the library root), optionally under a subdirectory, and return both the absolute path and a path suitable for use in the current Markdown document.
Typical use case: when importing Word/HTML, extract embedded data: images into an images/ subfolder and insert relative paths into Markdown.
// Example: decode a data:URL image, write it to images subfolder
const { data, fileName } = dataUrlToBytes(dataUrl, 'example', 1);
const { fullPath, relativePath } = await context.saveBinaryToCurrentFolder({
fileName, // e.g. 'example-001.png'
data, // Uint8Array / ArrayBuffer / number[]
subDir: 'images', // optional, default is the base folder itself
onConflict: 'renameAuto', // 'overwrite' | 'renameAuto' | 'error'
});
// Then write a relative reference in the current document:
// 
const md = ``;Parameters:
fileName: target file name (no path); invalid path characters will be sanitized;data: binary payload to write, supportsUint8Array/ArrayBuffer/number[];subDir(optional): subdirectory relative to the base folder, e.g.imagesorassets/images;onConflict(optional): how to handle name conflicts:'overwrite': overwrite existing file;'renameAuto'(default): append-1,-2, etc. until no conflict;'error': throw if the target already exists.
Behavior:
- Base folder is the directory of the currently open document when possible; otherwise falls back to the library root;
- When
subDiris specified, the implementation attempts to create it recursively; failures are surfaced via write errors or left to callers to handle; - The final write path is always constrained to stay inside the current library, never outside;
- Return value:
fullPath: absolute path of the written file;relativePath: path relative to the current document, suitable for Markdown references such asimages/foo.png.
Open local document by given absolute path, equivalent to user opening the file in the interface.
// Open single document
await context.openFileByPath('C:/docs/note.md');
// Can continue to read content after opening
const content = context.getEditorValue();
context.ui.notice('Opened document, length: ' + content.length, 'ok');Note:
- Only supports document types currently supported by flyMD (
md / markdown / txt / pdf). - Uses internal app opening process, updates current document path, recent files, and other states.
Create a sticky note window: open specified file in new instance in sticky note mode, automatically enter focus mode + read mode + close library sidebar, and display sticky note control buttons (lock dragging/window always on top).
// Open current document as sticky note
const currentFile = 'C:/notes/todo.md';
await context.createStickyNote(currentFile);
context.ui.notice('Sticky note created', 'ok');
// Or trigger from plugin menu
context.addMenuItem({
label: 'Quick Notes',
children: [
{
label: 'Create Todo Sticky Note',
onClick: async () => {
const todoFile = await context.storage.get('todoFilePath');
if (todoFile) {
await context.createStickyNote(todoFile);
} else {
context.ui.notice('Please set todo file path first', 'err');
}
}
}
]
});Features:
- Sticky note window automatically resizes to 400×300 pixels and moves to top-right corner
- Automatically enters focus mode (hides native titlebar)
- Automatically switches to read mode
- Automatically closes library sidebar
- Displays two control buttons (only visible in sticky note mode):
- Pin button: Lock window position (disable dragging)
- Top button: Keep window always on top
Parameters:
filePath(string, required): Absolute path of file to open in sticky note mode
Notes:
- File must be saved to disk (have absolute path)
- Only supports text file types (
.md,.markdown,.txt) - Sticky note window can still switch back to edit mode, user retains full editing capability
- Sticky note mode doesn't affect main window, both can run simultaneously
Practical Example: Quick Todo Sticky Notes
export function activate(context) {
let quickNoteFiles = [];
context.addMenuItem({
label: 'Sticky Note Tools',
children: [
{
label: 'Add Quick Sticky Note',
onClick: async () => {
const files = await context.pickDocFiles({ multiple: true });
if (files && files.length > 0) {
quickNoteFiles = [...quickNoteFiles, ...files];
await context.storage.set('quickNotes', quickNoteFiles);
context.ui.notice(`Added ${files.length} sticky notes`, 'ok');
}
}
},
{ type: 'divider' },
{
type: 'group',
label: 'Quick Sticky Notes'
},
...quickNoteFiles.map(file => ({
label: file.split(/[/\\]/).pop(),
note: '📌',
onClick: async () => {
await context.createStickyNote(file);
}
}))
]
});
// Load saved quick notes list on startup
context.storage.get('quickNotes').then(saved => {
if (saved) quickNoteFiles = saved;
});
}Export current document to PDF file, target path specified by plugin.
// Export current document to specified path
await context.exportCurrentToPdf('C:/docs/note.pdf');
context.ui.notice('PDF export completed', 'ok');Note:
- Only available in desktop version (Tauri app), depends on built-in PDF export capability.
targetshould be complete file path (including.pdfextension), invalid path throws error.- Plugin doesn't need to handle rendering details, export content matches "Save as PDF" in app.
Register plugin API, allowing other plugins to call it. Use to make current plugin an "infrastructure plugin" providing services to others.
export function activate(context) {
// Register utility functions API
context.registerAPI('my-utils', {
// Export utility functions
formatDate: (date) => {
return date.toISOString().split('T')[0];
},
chunk: (array, size) => {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
},
debounce: (fn, delay) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
});
context.ui.notice('Utility library API registered', 'ok');
}Parameters:
namespace(string): API namespace, must be unique. Recommended to use plugin ID or descriptive nameapi(any): API object to export, can be function, object, class or any JavaScript value
Notes:
- Namespace must be unique, registration fails with console warning if already occupied by another plugin
- Registered APIs are automatically cleaned up when plugin is unloaded
- Recommended to register API in
activatefunction to ensure API is available when plugin is enabled
Get API registered by other plugins.
export function activate(context) {
// Try to get utility library API
const utils = context.getPluginAPI('my-utils');
if (!utils) {
context.ui.notice('Please install my-utils plugin first', 'err');
return;
}
// Use API provided by other plugin
const today = utils.formatDate(new Date());
context.ui.notice('Today is: ' + today, 'ok');
// Use chunk function
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const chunks = utils.chunk(numbers, 3);
console.log('Chunked result:', chunks); // [[1,2,3], [4,5,6], [7,8,9]]
}Parameters:
namespace(string): API namespace to get
Return Value:
- Returns corresponding API object if exists
- Returns
nullif doesn't exist
Best Practices:
- Check if API exists before use (whether return value is
null) - If depending on other plugins, can specify dependency in
manifest.json - Recommended to provide complete documentation for infrastructure plugins
1. Base Utility Library Plugin (lodash-lite)
// lodash-lite/manifest.json
{
"id": "lodash-lite",
"name": "Lodash Utility Library (Lite)",
"version": "1.0.0",
"description": "Provide common utility functions for other plugins",
"main": "main.js"
}// lodash-lite/main.js
export function activate(context) {
// Register utility functions API
context.registerAPI('lodash', {
// Array processing
chunk: (arr, size) => {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
},
uniq: (arr) => [...new Set(arr)],
flatten: (arr) => arr.flat(),
// Object processing
pick: (obj, keys) => {
const result = {};
keys.forEach(key => {
if (key in obj) result[key] = obj[key];
});
return result;
},
// String processing
capitalize: (str) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(),
camelCase: (str) => {
return str.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : '');
},
// Function utilities
debounce: (fn, delay) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
});
context.ui.notice('Lodash utility library loaded', 'ok', 1500);
}2. Data Processing Plugin (Using Utility Library)
// markdown-processor/manifest.json
{
"id": "markdown-processor",
"name": "Markdown Batch Processing Tool",
"version": "1.0.0",
"description": "Batch process Markdown files (depends on lodash-lite)",
"main": "main.js"
}// markdown-processor/main.js
export function activate(context) {
// Get utility library API
const _ = context.getPluginAPI('lodash');
if (!_) {
context.ui.notice('Please install lodash-lite plugin first', 'err', 3000);
return;
}
// Add menu item
context.addMenuItem({
label: 'Batch Process',
children: [
{
label: 'Extract All Headers',
onClick: async () => {
const content = context.getEditorValue();
const lines = content.split('\n');
// Extract header lines
const headers = lines.filter(line => line.trim().startsWith('#'));
// Deduplicate (using lodash API)
const uniqueHeaders = _.uniq(headers);
context.ui.notice(`Found ${uniqueHeaders.length} unique headers`, 'ok');
console.log('Header list:', uniqueHeaders);
}
},
{
label: 'Format Links',
onClick: () => {
const content = context.getEditorValue();
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let links = [];
let match;
while ((match = linkRegex.exec(content)) !== null) {
links.push({ text: match[1], url: match[2] });
}
// Deduplicate (using lodash API)
const uniqueLinks = _.uniq(links.map(l => l.url));
context.ui.notice(`Document contains ${uniqueLinks.length} unique links`, 'ok');
}
}
]
});
context.ui.notice('Markdown batch processing tool loaded', 'ok', 1500);
}Workflow:
- User first installs
lodash-litebase utility library plugin lodash-literegisters utility functions viaregisterAPI('lodash', ...)on activation- User installs and enables
markdown-processorplugin markdown-processorgets utility functions viagetPluginAPI('lodash')- If utility library doesn't exist, prompts user to install; otherwise uses utility functions normally
Advantages:
- Reuse base functionality, avoid duplicate implementation
- Smaller plugin size, only implement business logic
- Ecosystem building: layered architecture of infrastructure plugins + business plugins
flyMD has a built-in theme system and exposes optional Theme extension APIs for plugins to extend or override "color palettes, typography styles, and Markdown rendering styles".
- Color palette: Append selectable colors to theme panel (for edit/read/wysiwyg backgrounds)
- Typography: Override CSS for existing typography styles (fonts/sizes/line heights, etc.)
- Markdown style: Override CSS for existing styles (headers, quotes, code blocks, tables, etc.)
- Theme preferences: Read/save/apply current theme settings
- Theme events: Listen to theme changes, link plugin UI
Note: Current version ID lists are fixed sets; registering non-existent IDs will be ignored.
- Typography ID:
default | serif | modern | reading | academic - Markdown Style ID:
standard | github | notion | journal | card | docs
Can directly access global object in render process: window.flymdTheme
interface ThemePrefs {
editBg: string // Edit background
readBg: string // Read background
wysiwygBg: string // WYSIWYG background
typography: 'default' | 'serif' | 'modern' | 'reading' | 'academic'
mdStyle: 'standard' | 'github' | 'notion' | 'journal' | 'card' | 'docs'
}
// Extension entry points
flymdTheme.registerPalette(label: string, color: string, id?: string): void
flymdTheme.registerTypography(id: ThemePrefs['typography'], label: string, css?: string): void
flymdTheme.registerMdStyle(id: ThemePrefs['mdStyle'], label: string, css?: string): void
// Theme state
flymdTheme.applyThemePrefs(prefs: ThemePrefs): void
flymdTheme.saveThemePrefs(prefs: ThemePrefs): void
flymdTheme.loadThemePrefs(): ThemePrefs
// Theme change events (plugins can listen and link)
window.addEventListener('flymd:theme:changed', (e) => {
const prefs = (e.detail || {}).prefs
console.log('Theme changed:', prefs)
})// main.js (plugin)
export function activate(context) {
// 1) Add two selectable colors to theme panel
flymdTheme.registerPalette('Lavender', '#ede9fe')
flymdTheme.registerPalette('Mint Green', '#e8fff4')
// 2) Append/override CSS for Docs style (only takes effect in md-docs)
flymdTheme.registerMdStyle('docs', 'Docs', `
.container.md-docs { --c-key:#1f4eff; --c-str:#0ea5e9; --c-num:#d97706; --c-fn:#7c3aed; --c-com:#94a3b8; }
@media (prefers-color-scheme: dark) {
.container.md-docs { --c-key:#93c5fd; --c-str:#67e8f9; --c-num:#fdba74; --c-fn:#c4b5fd; --c-com:#9ca3af; }
}
`)
// 3) Quickly apply a theme preference (example: switch read background to lavender)
const prefs = flymdTheme.loadThemePrefs()
prefs.readBg = '#ede9fe'
flymdTheme.saveThemePrefs(prefs)
flymdTheme.applyThemePrefs(prefs)
context.ui.notice('Theme extension loaded', 'ok')
}export function activate() {
// Append larger line height for "reading" typography style (won't affect other styles)
flymdTheme.registerTypography('reading', 'Reading', `
.container.typo-reading .preview-body,
.container.typo-reading.wysiwyg-v2 .ProseMirror { line-height: 2.0; font-size: 18px; }
`)
}- Layout base colors
--bgEdit background (applied to.containerscope)--preview-bgRead background (.container:not(.wysiwyg):not(.wysiwyg-v2) .preview)--wysiwyg-bgWYSIWYG background (.container.wysiwyg-v2)
- Code coloring (highlight tokens)
--code-bg,--code-border,--code-fg--c-key,--c-str,--c-num,--c-fn,--c-com
- Code block decoration
--code-pre-pad-yCode block base vertical padding (combined with language badge spacing)--code-lang-gapLanguage badge spacing extra height (defined in.codebox)
- Avoid directly overriding
.codebox prepadding-top, uniformly use--code-pre-pad-y + --code-lang-gapfor spacing to prevent language badge overlapping first line. - Typography/MdStyle
idare currently fixed sets; can passcssto refine/override existing styles. - Using
applyThemePrefsto modify theme only affects current session; combine withsaveThemePrefsto persist to next startup. - Listen to
flymd:theme:changedevent to implement plugin UI and theme linkage updates.
Called when plugin is activated (required).
export function activate(context) {
console.log('Plugin activated');
// Initialize plugin
context.addMenuItem({
label: 'My Feature',
onClick: async () => {
// Feature implementation
}
});
}Called when plugin is deactivated (optional).
export function deactivate() {
console.log('Plugin deactivated');
// Clean up resources
}Open plugin settings interface (optional).
export function openSettings(context) {
// Read config from storage
const loadConfig = async () => {
const apiKey = await context.storage.get('apiKey') || '';
const apiUrl = await context.storage.get('apiUrl') || '';
return { apiKey, apiUrl };
};
// Save config
const saveConfig = async (config) => {
await context.storage.set('apiKey', config.apiKey);
await context.storage.set('apiUrl', config.apiUrl);
context.ui.notice('Configuration saved', 'ok');
};
// Create settings interface (example: using prompt)
const showSettings = async () => {
const config = await loadConfig();
const apiKey = prompt('Enter API Key:', config.apiKey);
if (apiKey !== null) {
const apiUrl = prompt('Enter API URL:', config.apiUrl);
if (apiUrl !== null) {
await saveConfig({ apiKey, apiUrl });
}
}
};
showSettings();
}// main.js
export function activate(context) {
context.addMenuItem({
label: 'Word Count',
title: 'Count characters, words, and lines in current document',
onClick: () => {
const content = context.getEditorValue();
const chars = content.length;
const words = content.split(/\s+/).filter(w => w.length > 0).length;
const lines = content.split('\n').length;
context.ui.notice(
`Characters: ${chars} | Words: ${words} | Lines: ${lines}`,
'ok',
3000
);
}
});
}// manifest.json
{
"id": "word-count",
"name": "Word Count",
"version": "1.0.0",
"author": "Your Name",
"description": "Count characters, words, and lines in Markdown documents",
"main": "main.js"
}// main.js
export function activate(context) {
context.addMenuItem({
label: 'Uppercase Conversion',
title: 'Convert selected text to uppercase',
onClick: async () => {
const content = context.getEditorValue();
const confirmed = await context.ui.confirm('Convert all text to uppercase?');
if (confirmed) {
const upperCase = content.toUpperCase();
context.setEditorValue(upperCase);
context.ui.notice('Conversion completed!', 'ok');
}
}
});
}// main.js
export function activate(context) {
context.addMenuItem({
label: 'Get IP',
title: 'Get current public IP address',
onClick: async () => {
try {
const response = await context.http.fetch('https://api.ipify.org?format=json', {
method: 'GET'
});
const data = await response.json();
context.ui.notice(`Your IP address is: ${data.ip}`, 'ok', 3000);
} catch (error) {
context.ui.notice('Failed to get IP: ' + error.message, 'err', 3000);
}
}
});
}// main.js
export function activate(context) {
context.addMenuItem({
label: 'My Tool',
onClick: async () => {
// Read config
const prefix = await context.storage.get('prefix') || '>> ';
// Use config
const content = context.getEditorValue();
const lines = content.split('\n');
const prefixed = lines.map(line => prefix + line).join('\n');
context.setEditorValue(prefixed);
context.ui.notice('Prefix added', 'ok');
}
});
}
export function openSettings(context) {
(async () => {
const currentPrefix = await context.storage.get('prefix') || '>> ';
const newPrefix = prompt('Set line prefix:', currentPrefix);
if (newPrefix !== null) {
await context.storage.set('prefix', newPrefix);
context.ui.notice('Settings saved', 'ok');
}
})();
}-
Create GitHub Repository
git init git add . git commit -m "Initial commit" git remote add origin https://github.com/username/my-plugin.git git push -u origin main
-
File Structure
Ensure repository root contains:
manifest.jsonmain.jsREADME.md(recommended)
-
Installation Method
Users can install via following formats:
username/my-plugin username/my-plugin@main username/my-plugin@develop
-
Deploy Files
Deploy plugin files to web server:
https://example.com/plugins/my-plugin/ ├── manifest.json └── main.js -
Ensure CORS
Server needs to allow cross-origin access:
Access-Control-Allow-Origin: * -
Installation Method
Users install via complete URL:
https://example.com/plugins/my-plugin/manifest.json
Send plugin/extension address and description to fly@llingfei.com or submit an issue
Always use try-catch to handle potential errors:
export function activate(context) {
context.addMenuItem({
label: 'My Feature',
onClick: async () => {
try {
// Operations that might fail
const data = await context.http.fetch('https://api.example.com');
// Process data
} catch (error) {
context.ui.notice('Operation failed: ' + error.message, 'err', 3000);
console.error('Detailed error:', error);
}
}
});
}Provide timely feedback on operation status:
export function activate(context) {
context.addMenuItem({
label: 'Upload',
onClick: async () => {
context.ui.notice('Uploading...', 'ok', 999999); // Long duration display
try {
await uploadFunction();
context.ui.notice('Upload successful!', 'ok', 2000);
} catch (error) {
context.ui.notice('Upload failed', 'err', 3000);
}
}
});
}Validate data before operations:
export function activate(context) {
context.addMenuItem({
label: 'Process',
onClick: async () => {
const content = context.getEditorValue();
if (!content || content.trim().length === 0) {
context.ui.notice('Editor content is empty', 'err');
return;
}
// Continue processing...
}
});
}Provide reasonable default configurations for plugins:
async function getConfig(context) {
return {
apiKey: await context.storage.get('apiKey') || '',
timeout: await context.storage.get('timeout') || 5000,
enabled: await context.storage.get('enabled') ?? true
};
}Consider compatibility across different environments:
export function activate(context) {
// Check if required APIs are available
if (!context.http) {
context.ui.notice('HTTP functionality unavailable', 'err');
return;
}
// Continue initialization...
}Understand plugin variable scope to avoid naming conflicts:
Storage Space (Fully Isolated)
Each plugin's context.storage is completely independent and won't conflict with other plugins:
// plugin-a
export function activate(context) {
await context.storage.set('count', 1); // ✅ Independent storage
}
// plugin-b
export function activate(context) {
await context.storage.set('count', 2); // ✅ Independent storage, won't overwrite plugin-a
}Module-level Variables (Local Scope)
Variables inside modules are local by default and won't conflict:
// plugin-a/main.js
const privateData = { count: 1 }; // ✅ Local variable
export function activate(context) {
console.log(privateData.count); // ✅ Can access
}
// Other plugins cannot access privateDataGlobal Object window (Shared)
Directly mounting variables on window may conflict with other plugins:
// ❌ Not recommended: Pollutes global namespace
export function activate(context) {
window.myData = { count: 1 }; // May conflict with other plugins
}
// ✅ Recommended: Use namespace
export function activate(context) {
window.__pluginData__ = window.__pluginData__ || {};
window.__pluginData__['my-plugin-id'] = { count: 1 };
}
// ✅ Best: Prefer module scope or context.storage
const myData = { count: 1 }; // Module variable
// or
await context.storage.set('myData', { count: 1 }); // Persistent storageDOM Element IDs (Shared)
Avoid using simple ID names:
// ❌ Not recommended: May conflict with other plugins
const panel = document.createElement('div');
panel.id = 'panel';
// ✅ Recommended: Use unique IDs
const panel = document.createElement('div');
panel.id = 'my-plugin-panel-' + Math.random().toString(36).slice(2);- Prefer
context.storage- Persistent storage with automatic isolation - Use module scope -
const/letvariables are local by default - Avoid global pollution - Don't mount variables directly on
window - Use unique IDs - Add plugin prefix or random string to DOM element IDs
- Share via API - Use
context.registerAPI()to share functionality safely
// ✅ Complete example: Good isolation practices
const pluginState = {
count: 0,
data: []
};
export async function activate(context) {
// Load from persistent storage
const savedCount = await context.storage.get('count') || 0;
pluginState.count = savedCount;
// Create unique DOM element
const panel = document.createElement('div');
panel.id = `my-plugin-panel-${Date.now()}`;
panel.className = 'my-plugin-panel';
// Register API for other plugins
context.registerAPI('my-plugin', {
getCount: () => pluginState.count,
increment: () => {
pluginState.count++;
context.storage.set('count', pluginState.count);
}
});
}A: Use console.log to output debug information, press F12 or Ctrl+Shift+I in flyMD to open developer tools to view.
export function activate(context) {
console.log('Plugin activated', context);
context.addMenuItem({
label: 'Debug',
onClick: () => {
console.log('Current content:', context.getEditorValue());
}
});
}A: Yes, through context.invoke to call Tauri backend commands to access the file system.
A: Currently need to remove old version first, then reinstall new version.
A: No hard limits, but recommended to only store necessary configuration data, avoid storing large amounts of data.
A: Each plugin can only add one main menu item, but can pop up submenus in the menu item's click event.
- Typecho Publisher Plugin - Official example plugin
- flyMD GitHub Repository
- Tauri Documentation
This document follows the same license as the project: flyMD Non-Commercial Open Source License Agreement (NC 1.0), see LICENSE.
If you have questions or suggestions, welcome to submit an Issue.