Build, test, and maintain Obsidian plugins using TypeScript and the Obsidian API. Use when creating new Obsidian plugins, adding features to existing plugins, debugging plugin issues, configuring esbuild/TypeScript, writing custom views, commands, settings, modals, editor extensions, or preparing plugins for release. Keywords: obsidian, plugin, vault, workspace, itemview, modal, command, settings, codemirror, esbuild, manifest.json, community plugin.
You are an expert Obsidian plugin developer. You build plugins using TypeScript and the Obsidian API (obsidian npm package). All code must follow Obsidian's official plugin guidelines and best practices.
Every Obsidian plugin has this structure:
my-plugin/
├── src/
│ ├── main.ts # Plugin entry point (extends Plugin)
│ └── settings.ts # Settings tab (extends PluginSettingTab)
├── styles.css # Optional plugin styles
├── manifest.json # Plugin metadata (REQUIRED)
├── package.json # npm configuration
├── tsconfig.json # TypeScript configuration
├── esbuild.config.mjs # Build configuration
├── versions.json # Version compatibility mapping
└── version-bump.mjs # Version bump helper script
Output files (generated by build):
main.js — bundled plugin code (CJS format)manifest.json — copied to releasestyles.css — copied to release (if present){
"id": "my-plugin-id",
"name": "My Plugin Name",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "What the plugin does.",
"author": "Author Name",
"authorUrl": "https://github.com/author",
"isDesktopOnly": false
}
id: Unique, kebab-case. Must NOT contain the word "obsidian".isDesktopOnly: Set true if using Node.js APIs (child_process, fs, etc.){
"name": "my-plugin",
"version": "1.0.0",
"description": "Plugin description",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
},
"devDependencies": {
"@types/node": "^16.11.6",
"esbuild": "0.25.5",
"tslib": "2.4.0",
"typescript": "^5.8.3"
},
"dependencies": {
"obsidian": "latest"
}
}
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from 'node:module';
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: { js: banner },
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtinModules
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
Critical: obsidian, electron, all @codemirror/*, @lezer/*, and Node.js builtins MUST be external. Obsidian provides these at runtime.
{
"compilerOptions": {
"baseUrl": "src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"importHelpers": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"strictNullChecks": true,
"strictBindCallApply": true,
"allowSyntheticDefaultImports": true,
"useUnknownInCatchVariables": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["src/**/*.ts"]
}
import { Plugin } from 'obsidian';
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
// 1. Load settings first
await this.loadSettings();
// 2. Register views
this.registerView(VIEW_TYPE, (leaf) => new MyView(leaf));
// 3. Register commands
this.addCommand({ id: 'my-cmd', name: 'My command', callback: () => {} });
// 4. Add ribbon icon
this.addRibbonIcon('icon-name', 'Tooltip', (evt) => {});
// 5. Add settings tab
this.addSettingTab(new MySettingTab(this.app, this));
// 6. Register events
this.registerEvent(this.app.vault.on('create', (file) => {}));
// 7. Register editor extensions (CodeMirror 6)
this.registerEditorExtension([myExtension]);
}
async onunload() {
// Clean up non-auto-managed resources only
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
Use for custom panels (sidebar, main area):
import { ItemView, WorkspaceLeaf } from 'obsidian';
export const VIEW_TYPE = 'my-custom-view';
export class MyView extends ItemView {
constructor(leaf: WorkspaceLeaf) { super(leaf); }
getViewType() { return VIEW_TYPE; }
getDisplayText() { return 'My View'; }
async onOpen() {
const container = this.contentEl;
container.empty();
container.createEl('h4', { text: 'My View' });
}
async onClose() { /* cleanup */ }
}
Activate in sidebar:
async activateView() {
const { workspace } = this.app;
const leaves = workspace.getLeavesOfType(VIEW_TYPE);
let leaf: WorkspaceLeaf;
if (leaves.length > 0) {
leaf = leaves[0];
} else {
leaf = workspace.getRightLeaf(false);
await leaf.setViewState({ type: VIEW_TYPE, active: true });
}
workspace.revealLeaf(leaf);
}
Rules:
getLeavesOfType() to find viewsonunload()// Simple command
this.addCommand({
id: 'my-command',
name: 'Do something',
callback: () => { /* action */ }
});
// Editor command (only when editor active)
this.addCommand({
id: 'editor-command',
name: 'Do with editor',
editorCallback: (editor: Editor, view: MarkdownView) => {
const selection = editor.getSelection();
}
});
// Conditional command
this.addCommand({
id: 'conditional-command',
name: 'Maybe do',
checkCallback: (checking: boolean) => {
const canRun = /* some condition */;
if (canRun) {
if (!checking) { /* execute */ }
return true;
}
return false;
}
});
Do NOT set default hotkeys for community plugins.
import { App, PluginSettingTab, Setting } from 'obsidian';
export class MySettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('My setting')
.setDesc('Description of setting')
.addText((text) =>
text
.setPlaceholder('placeholder')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
})
);
}
}
Setting types: .addText(), .addTextArea(), .addToggle(), .addDropdown(), .addSlider(), .addButton(), .addExtraButton(), .addSearch(), .addColorPicker(), .addProgressBar(), .addMomentFormat().
Headings: new Setting(containerEl).setName('Section').setHeading();
import { App, Modal, Setting } from 'obsidian';
class InputModal extends Modal {
result: string;
onSubmit: (result: string) => void;
constructor(app: App, onSubmit: (result: string) => void) {
super(app);
this.onSubmit = onSubmit;
}
onOpen() {
this.setTitle('Enter input');
new Setting(this.contentEl)
.setName('Value')
.addText((text) => text.onChange((value) => { this.result = value; }));
new Setting(this.contentEl)
.addButton((btn) =>
btn.setButtonText('Submit').setCta().onClick(() => {
this.close();
this.onSubmit(this.result);
})
);
}
onClose() { this.contentEl.empty(); }
}
Suggest modals: SuggestModal<T> (manual) or FuzzySuggestModal<T> (with fuzzy search).
this.addRibbonIcon('dice', 'Tooltip text', (evt: MouseEvent) => {
// action
});
Always provide a command alternative — users can hide ribbon.
const statusBar = this.addStatusBarItem();
statusBar.setText('Status text');
// Editor context menu
this.registerEvent(
this.app.workspace.on('editor-menu', (menu, editor, view) => {
menu.addItem((item) =>
item.setTitle('My action').setIcon('icon').onClick(() => {})
);
})
);
// File context menu
this.registerEvent(
this.app.workspace.on('file-menu', (menu, file) => {
menu.addItem((item) =>
item.setTitle('My action').setIcon('icon').onClick(() => {})
);
})
);
// List files
const files = this.app.vault.getMarkdownFiles(); // .md only
const allFiles = this.app.vault.getFiles(); // all files
// Read
const content = await this.app.vault.cachedRead(file); // for display
const content = await this.app.vault.read(file); // for modify
// Atomic modify (PREFERRED)
await this.app.vault.process(file, (data) => {
return data.replace('old', 'new');
});
// Create
await this.app.vault.create('path/to/file.md', 'content');
// Delete
await this.app.vault.trash(file, true); // true = system trash
// Lookup by path
const file = this.app.vault.getFileByPath('folder/note.md');
const folder = this.app.vault.getFolderByPath('folder');
// Frontmatter (atomic)
this.app.fileManager.processFrontMatter(file, (fm) => {
fm.key = 'value';
});
Rules:
cachedRead() for display, read() for modifyprocess() over read() + modify() (atomic)normalizePath() for user-provided pathsgetFileByPath()// Always use registerEvent for auto-cleanup
this.registerEvent(this.app.vault.on('create', (file) => {}));
this.registerEvent(this.app.vault.on('modify', (file) => {}));
this.registerEvent(this.app.vault.on('delete', (file) => {}));
this.registerEvent(this.app.vault.on('rename', (file, oldPath) => {}));
this.registerEvent(this.app.workspace.on('file-open', (file) => {}));
this.registerEvent(this.app.workspace.on('active-leaf-change', (leaf) => {}));
// Timers — use registerInterval for auto-cleanup
this.registerInterval(window.setInterval(() => {}, 1000));
setIcon(element, 'icon-name') — add icon to elementaddIcon('name', '<svg-content />') — register custom icon (100×100 viewBox)containerEl.createEl('tag', { cls: 'class', text: 'content' }) — NEVER innerHTMLcreateDiv(), createSpan() convenience methodsstyles.css — NEVER hardcode colors--text-normal, --text-muted, --background-primary, --background-secondary, --background-modifier-border, --interactive-accent, etc.element.toggleClass('active', condition) for conditional stylingnew Setting(containerEl).setName('Title').setHeading() — NOT <h1>, <h2>innerHTML, outerHTML, or insertAdjacentHTMLcreateEl, createDiv, setText, etc.)el.empty() to clear element contentsapp global — use this.app from plugin instanceconsole.log — only error messages in productionasync/await over raw Promisesconst and let, never varworkspace.getActiveViewOfType(MarkdownView) — not workspace.activeLeafFor Live Preview customization:
import { Extension } from '@codemirror/state';
// Register in onload()
this.registerEditorExtension([myExtension]);
// To update dynamically:
// 1. Keep reference to the array (not a new array)
// 2. Mutate in-place: this.editorExtension.length = 0; this.editorExtension.push(newExt);
// 3. Call this.app.workspace.updateOptions();
version in manifest.jsonversions.json with version → minAppVersion mappingnpm run buildmain.js, manifest.json, styles.css (if any) as release assetsobsidianmd/obsidian-releases adding entry to community-plugins.jsonthis.addCommand({
id: 'open-my-view',
name: 'Open my view',
callback: () => this.activateView(),
});
this.addRibbonIcon('layout-panel-right', 'Open my view', () => {
this.activateView();
});
const editor = this.app.workspace.activeEditor?.editor;
if (editor) {
const selection = editor.getSelection();
const fullContent = editor.getValue();
const cursor = editor.getCursor();
}
const file = this.app.workspace.getActiveFile();
if (file) {
const content = await this.app.vault.cachedRead(file);
}
See references/API-REFERENCE.md for the full TypeScript API surface.