Obsidian Plugin Development
When This Skill Applies
Use this skill when the user is:
- Creating a new Obsidian plugin from scratch
- Implementing plugin features (commands, views, modals, settings, editor extensions)
- Debugging plugin issues or unexpected behavior
- Configuring build tools (Vite, esbuild, rollup)
- Writing tests for Obsidian plugins
- Setting up CI/CD and release workflows
- Preparing a plugin for community submission
- Working with CodeMirror 6 editor extensions
- Integrating React/Svelte/Vue into Obsidian views
Critical Rules (Always Follow)
| # | Rule | Why |
|---|---|---|
| 1 | Never use global app — use this.app | Global app breaks in multi-window; submission rejected |
| 2 | Never use innerHTML/outerHTML — use createEl(), createDiv(), setText() | XSS vulnerability; instant rejection |
| 3 | Use registerEvent() for all event subscriptions | Auto-cleanup on unload; prevents memory leaks |
| 4 | No default hotkeys — let users configure | Hotkey conflicts with other plugins |
| 5 | Use requestUrl() over fetch() | Bypasses CORS; works on mobile |
| 6 | Use normalizePath() for user-provided paths | Handles cross-platform path differences |
| 7 | Prefer vault.process() over vault.modify() | Atomic operation; safe with concurrent edits |
| 8 | Use FileManager.processFrontMatter() for YAML | Never parse/serialize YAML manually |
| 9 | Use Sentence case for all UI text | Obsidian convention; submission requirement |
| 10 | Use setHeading() not <h1>/<h2> | Semantic; supports RTL; submission requirement |
| 11 | Import only what you use — no unused classes | Cleaner code; easier audits; submission reviewers check this |
| 12 | Use checkCallback when command depends on context | callback = always available; checkCallback = conditionally shown; editorCallback = needs editor |
| 13 | Always provide .theme-dark / .theme-light CSS variants | Obsidian CSS vars auto-adapt, but explicit theme blocks ensure edge cases render correctly; submission reviewers check this |
| 14 | No regex lookbehind — (?!...) OK, (?<=...) NOT OK | Breaks on iOS Safari < 16.4; submission rejected |
| 15 | All interactive elements keyboard accessible | Tab navigation + Enter/Space; submission requirement |
| 16 | ARIA labels on all icon-only buttons | Screen reader support; submission requirement |
| 17 | Touch targets ≥ 44×44px | Mobile usability; submission requirement |
| 18 | Use vault.configDir not .obsidian | Cross-platform compatibility; submission requirement |
| 19 | Use fileManager.trashFile() not vault.delete() | Respects user trash settings |
| 20 | Use AbstractInputSuggest not TextInputSuggest | Built-in API; Liam's copy-pasted implementation is banned |
| 21 | Create versions.json — maps plugin version → min Obsidian version | Submission bot checks for it; auto-reject if missing |
| 22 | Version your settings schema — _settingsVersion field | Enables migration pipeline on upgrade; prevents data loss |
Quick Reference
Plugin Lifecycle
import { Plugin } from 'obsidian'
export default class MyPlugin extends Plugin {
async onload() {
// 1. Load settings FIRST
await this.loadSettings()
// 2. Add settings tab
this.addSettingTab(new MySettingTab(this.app, this))
// 3. Register commands
this.addCommand({ id: 'my-command', name: 'My command', callback: () => {} })
// 4. Register views
this.registerView(MY_VIEW_TYPE, (leaf) => new MyView(leaf))
// 5. Register editor extensions
this.registerEditorExtension(myExtension)
// 6. Register events
this.registerEvent(this.app.vault.on('modify', (file) => {}))
this.registerDomEvent(document, 'click', (evt) => {})
this.registerInterval(window.setInterval(() => {}, 1000))
}
async onunload() {
// Resources registered with register*() are auto-cleaned
// Manual cleanup needed for: MutationObserver, React root, vault.on() in React
}
}
Essential API Cheatsheet
| Need | API |
|---|---|
| Get active file | this.app.workspace.getActiveFile() |
| Read file | this.app.vault.cachedRead(file) |
| Modify file (background) | this.app.vault.process(file, (data) => data) |
| Modify file (editor) | editor.replaceSelection(), editor.getRange() |
| Create file | this.app.vault.create(path, content) |
| Delete file | this.app.fileManager.trashFile(file) |
| Rename file | this.app.fileManager.renameFile(file, newPath) |
| Read frontmatter | this.app.metadataCache.getFileCache(file)?.frontmatter |
| Write frontmatter | this.app.fileManager.processFrontMatter(file, (fm) => {}) |
| Show notification | new Notice('message', duration) |
| Open modal | new MyModal(this.app).open() |
| Get active editor | this.app.workspace.activeEditor?.editor |
| Platform check | Platform.isMacOS, Platform.isMobile, Platform.isDesktop |
| Network request | requestUrl({ url, method, headers, body }) |
| Persist data | this.loadData() / this.saveData(data) |
| Secure storage | this.app.secretStorage.setSecret(id, value) (v1.11.4+) |
| Detect theme | document.body.classList.contains('theme-dark') |
Command Callback Decision Tree
Does the command need an active editor?
├─ YES → editorCallback
│ (automatically hidden when no editor; gives you editor + view)
│
└─ NO → Does it need any context to run? (active file, leaf, etc.)
├─ YES → checkCallback
│ (return true when available; run action on !checking)
│
└─ NO → callback
(always visible, always runs)
Examples:
// Always available — no conditions
this.addCommand({
id: 'open-settings',
name: 'Open plugin settings',
callback: () => { this.openSettings() }
})
// Needs active file — use checkCallback
this.addCommand({
id: 'copy-stats',
name: 'Copy note statistics',
checkCallback: (checking) => {
const file = this.app.workspace.getActiveFile()
if (file) {
if (!checking) this.copyStats(file)
return true
}
return false
}
})
// Needs editor — use editorCallback
this.addCommand({
id: 'wrap-callout',
name: 'Wrap selection in callout',
editorCallback: (editor) => {
const sel = editor.getSelection()
editor.replaceSelection(`> [!note]\n> ${sel}`)
}
})
Import Hygiene
Only import what you actually use. Submission reviewers flag unused imports.
// Good — only what's needed
import { MarkdownView, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'
// Bad — unused imports
import { App, Editor, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'
// ^^^ ^^^^^^ ^^^^^ — never used
Common Pitfalls
- Storing view references → use
getLeavesOfType()on demand - Passing plugin as Component → use
this.addChild()instead - Detaching leaves in onunload → they reinitialize on update
- Not removing sample code →
MyPlugin,SampleSettingTabmust be renamed - Using
vault.modify()on active file → use Editor API instead - Manual YAML parsing → use
processFrontMatter()instead fetch()for API calls → userequestUrl()instead- Hardcoded colors in CSS → use
var(--text-normal), etc. navigator.platform→ usePlatform.isMacOSinsteadvardeclarations → useconst/letinstead- Promise chains → use
async/awaitinstead console.login production → remove or useconsole.debugwith conditional- Regex lookbehind
(?<=...)→ breaks on iOS Safari < 16.4; use alternative patterns Object.assign(defaults, saved)→ mutates defaults; useObject.assign({}, defaults, saved)- Hardcoded
.obsidianpath → usethis.app.vault.configDirinstead - Shallow merge for nested settings → use deep merge; shallow merge loses nested defaults
vault.delete()for removing files → usefileManager.trashFile()to respect user settings- Liam's
TextInputSuggest→ use built-inAbstractInputSuggestinstead - Missing
styles.css→ create empty file if no styles (submission bot checks for it) - Missing
versions.json→ create with{ "1.0.0": "1.0.0" }(submission bot checks for it) - No settings version tracking → add
_settingsVersionto settings interface for migration support
Detailed References
| Topic | File | When to Load |
|---|---|---|
| Lifecycle & Core API | reference/lifecycle.md | Always; building any plugin feature |
| ESLint Rules (28 rules) | reference/eslint-rules.md | ESLint setup, pre-submission audit, rule reference |
| Accessibility (MANDATORY) | reference/accessibility.md | Keyboard nav, ARIA labels, focus indicators, touch targets |
| CodeMirror 6 Editor Extensions | reference/editor-extensions.md | Editor decorations, syntax highlighting, live preview |
| React / Svelte / Vue Integration | reference/frameworks.md | Using React/Vue/Svelte in views or settings |
| Vault & File Operations | reference/vault-operations.md | File CRUD, frontmatter, events, caching |
| Settings & Data Migration | reference/settings-migration.md | Settings UI, load/save, deep merge, migration pipelines |
| Security & SecretStorage | reference/security.md | API keys, credentials, XSS prevention, network requests |
| CSS Styling | reference/css-accessibility.md | Theming, CSS variables, scoping, mobile styles |
| Dev Workflow & CLI | reference/dev-workflow.md | Build, hot-reload, CLI debugging, Obsidian CLI, ESLint config |
| Testing | reference/testing.md | Unit tests, mocking Obsidian API, Jest/Vitest |
| CI/CD & Release | reference/cicd-release.md | GitHub Actions, version bump, community submission |
Development Workflow
Quick Dev Loop (with Obsidian CLI)
# Build and hot-reload
npm run build && obsidian plugin:reload id=<plugin-id>
# Check for errors
obsidian dev:errors
# Inspect DOM
obsidian dev:dom selector=".my-plugin-view"
# Take screenshot
obsidian dev:screenshot
# Evaluate JS in Obsidian context
obsidian eval code="app.plugins.plugins"
Without Obsidian CLI
# Build and copy to test vault
npm run build && cp main.js manifest.json styles.css /path/to/TestVault/.obsidian/plugins/<plugin-id>/
# Then reload in Obsidian: Ctrl+P → "Reload app without saving"
Pre-Submission Checklist
Before creating a release or submitting to community plugins, verify:
Submission Validation (Bot checks — will auto-reject if incorrect)
-
idin manifest.json does not contain "obsidian"; doesn't end with "plugin"; lowercase only -
namedoes not contain "Obsidian"; doesn't end with "Plugin"; doesn't start with "Obsi" or end with "dian" -
descriptiondoes not contain "Obsidian" or "This plugin"; must end with.?!)punctuation; max 250 chars -
manifest.jsonid,name,descriptionmatch submission entry incommunity-plugins.json -
LICENSEfile present; copyright holder ≠ "Dynalist Inc."; year is current -
styles.cssexists (empty if no styles) -
versions.jsonexists with correct version mapping - GitHub release has
main.js,manifest.json,styles.cssattached
Code Quality
- All sample/template code removed (
MyPlugin,SampleSettingTab,SampleModal) - No
innerHTML/outerHTMLanywhere in code - No default hotkeys set
- No
console.login production (remove or use conditionalconsole.debug) - No unused imports
-
setHeading()used instead of<h2>in settings - Sentence case for all UI text (run ESLint to verify)
-
this.appused everywhere (not globalapp) - All resources cleaned up in
onunload() - No
Object.assign(defaults, saved)— useObject.assign({}, defaults, saved) - Use
fileManager.trashFile()notvault.delete() - No regex lookbehind (
(?<=...)) — breaks on iOS - Use
vault.configDirnot hardcoded.obsidian
Accessibility (MANDATORY)
- All interactive elements keyboard accessible (Tab, Enter, Space)
- ARIA labels on all icon-only buttons
-
:focus-visiblestyled with Obsidian CSS variables - Touch targets ≥ 44×44px
- Can use entire plugin without a mouse
ESLint & Release
- ESLint passes with
eslint-plugin-obsidianmd(npx eslint .) -
manifest.jsonversion correct,minAppVersionset -
isDesktopOnly: trueonly if using Node/Electron APIs
Reference Source Tracking
| Reference File | Primary Sources | Last Verified |
|---|---|---|
lifecycle.md | obsidian API docs, gapmiss/obsidian-plugin-skill | 2026-03 |
eslint-rules.md | obsidianmd/eslint-plugin v0.1.9, gapmiss/obsidian-plugin-skill | 2026-03 |
accessibility.md | gapmiss/obsidian-plugin-skill, obsidian plugin guidelines | 2026-03 |
editor-extensions.md | CM6 docs, @codemirror/view source | 2026-03 |
frameworks.md | Leonezz/obsidian-plugin-dev-skill, React docs | 2026-03 |
vault-operations.md | obsidian API docs, official plugin guidelines | 2026-03 |
settings-migration.md | Leonezz/obsidian-plugin-dev-skill | 2026-03 |
security.md | gapmiss/obsidian-plugin-skill, obsidian developer policies | 2026-03 |
css-accessibility.md | davidvkimball/obsidian-dev-skills, obsidian sample theme | 2026-03 |
dev-workflow.md | adriangrantdotorg/Obsidian-Skills, obsidian CLI docs | 2026-03 |
testing.md | Leonezz/obsidian-plugin-dev-skill | 2026-03 |
cicd-release.md | Leonezz/obsidian-plugin-dev-skill, obsidian submission docs | 2026-03 |
To update references: check each source for new content, cross-reference with obsidian developer docs changelog.
Design Decisions
- SKILL.md stays under 500 lines — quick reference + links to detailed docs
- Reference files are topic-based — load only what you need
- Code examples are real — from actual plugin patterns, not toy demos
- Do/Don't tables — clear before/after comparisons