All skills
gapmiss avatar

/obsidian

@ff97cb0

Comprehensive guidelines for Obsidian.md plugin development including ESLint rules from eslint-plugin-obsidianmd v0.4.2, TypeScript best practices, memory management, API usage (requestUrl vs fetch), UI/UX standards, popout window compatibility, community.obsidian.md submission process, and Scorecard optimization. Use when working with Obsidian plugins, main.ts files, manifest.json, Plugin class, MarkdownView, TFile, vault operations, or any Obsidian API development.

Use this Skill: https://skilld.dev/gh/gapmiss/obsidian-plugin-skill/obsidian

This session only. Nothing lands on disk.

referencecode-quality.md

≈4.6k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Code Quality & Best Practices

Code quality ensures maintainability, reliability, and better user experience.

Table of Contents


Remove Sample Code

Remove Sample Code

Rule: obsidianmd/no-sample-code

Remove all sample/template code before publishing:

  • Sample ribbon icons
  • Example status bar items
  • Template settings
  • Boilerplate comments

Rename Sample Class Names

Rule: obsidianmd/sample-names

❌ INCORRECT:

class MyPlugin extends Plugin { }
interface MyPluginSettings { }
class SampleSettingTab extends PluginSettingTab { }
class SampleModal extends Modal { }

✅ CORRECT:

class TodoPlugin extends Plugin { }
interface TodoPluginSettings { }
class TodoSettingTab extends PluginSettingTab { }
class TodoModal extends Modal { }

Rationale: Rename placeholder class names from the sample plugin template (MyPlugin, MyPluginSettings, SampleSettingTab, SampleModal) to meaningful names for your plugin.


Security Best Practices

Avoid innerHTML and outerHTML

Rule: Security best practice

❌ INCORRECT:

element.innerHTML = '<div>' + userContent + '</div>';
element.outerHTML = '<p>' + text + '</p>';

✅ CORRECT:

// Use DOM API
const div = element.createDiv();
div.textContent = userContent;

// Or use Obsidian helpers
const div = createDiv();
div.setText(userContent);

Rationale: Using innerHTML/outerHTML is a security risk (XSS vulnerability). Use DOM API or Obsidian helper functions instead.


Platform Compatibility

Avoid Regex Lookbehind

Rule: obsidianmd/regex-lookbehind

❌ INCORRECT:

const pattern = /(?<=@)\w+/;  // Not supported on some iOS versions

✅ CORRECT:

const pattern = /@(\w+)/;
const match = text.match(pattern);
const username = match?.[1];

Rationale: Regex lookbehind not supported on iOS versions before 16.4.


Use Platform API for OS Detection

Rule: obsidianmd/platform

❌ INCORRECT:

if (navigator.platform.includes('Mac')) { }
if (navigator.userAgent.includes('Windows')) { }
if (window.navigator.platform === 'Linux') { }

✅ CORRECT:

import { Platform } from 'obsidian';

if (Platform.isMacOS) { }
if (Platform.isWin) { }
if (Platform.isLinux) { }
if (Platform.isMobile) { }
if (Platform.isIosApp) { }
if (Platform.isAndroidApp) { }
if (Platform.isDesktopApp) { }

Rationale: Avoid using the navigator API to detect the operating system. Use Obsidian's Platform API instead for better reliability and mobile support.


Use window.setTimeout and window.setInterval

Rule: obsidianmd/prefer-window-timers (named prefer-active-window-timers before v0.4.0)

❌ INCORRECT:

const timer: NodeJS.Timeout = setTimeout(() => {
  // do something
}, 1000);

const interval = setInterval(() => {
  // do something
}, 1000);

// Also flagged — autofixed to window.setTimeout()
const other: number = activeWindow.setTimeout(() => {
  // do something
}, 1000);

✅ CORRECT:

const timer: number = window.setTimeout(() => {
  // do something
}, 1000);

const interval: number = window.setInterval(() => {
  // do something
}, 1000);

// Clear them with:
window.clearTimeout(timer);
window.clearInterval(interval);

Rationale: Timers are the one exception to "use activeWindow instead of window". prefer-window-timers flags both bare calls and activeWindow.* calls for setTimeout, setInterval, clearTimeout, clearInterval, and requestAnimationFrame, and autofixes both to window.*. prefer-active-doc deliberately skips window.<timer>(), so the two rules agree. activeWindow follows focus, so a timer scheduled on it could be scheduled on a popout that later closes, and a later activeWindow.clearTimeout() could target a different window than the one that owns the ID. Also use number type instead of NodeJS.Timeout for browser compatibility.


API Usage Best Practices

Don't Use Global app Object

Rule: Best practice from official guidelines

❌ INCORRECT:

// Don't use global app
const vault = app.vault;
const workspace = app.workspace;

✅ CORRECT:

// Use the plugin instance reference
const vault = this.app.vault;
const workspace = this.app.workspace;

Rationale: Always use this.app from your plugin instance instead of the global app object for better encapsulation and reliability.


Use requestUrl() Instead of fetch()

Rule: Best practice from official guidelines

❌ INCORRECT:

// Don't use fetch()
const response = await fetch('https://api.example.com/data');
const data = await response.json();

✅ CORRECT:

import { requestUrl } from 'obsidian';

// Use Obsidian's requestUrl() to bypass CORS
const response = await requestUrl('https://api.example.com/data');
const data = response.json;

Rationale: Don't use fetch(). Use Obsidian's requestUrl() instead to bypass CORS restrictions. The browser's fetch API is subject to CORS policies, but requestUrl() bypasses these restrictions.


Type requestUrl() Responses at the Boundary

Rule: Best practice (supports @typescript-eslint/no-unsafe-* and no-any guidance)

RequestUrlResponse.json is typed any in obsidian.d.ts, so reading it lets any leak silently into the rest of the plugin.

❌ INCORRECT:

const response = await requestUrl({ url, throw: false });
const items = response.json.results; // any — no type checking from here on

✅ CORRECT:

interface ListingsResponse {
  count: number;
  results: Listing[];
}

const response = await requestUrl({ url, throw: false });
if (response.status >= 400) {
  throw new ApiResponseError(response.status, response.text);
}
// Parse and cast once, in the API layer only
const data = JSON.parse(response.text) as ListingsResponse;
const items = data.results; // Listing[]

Rationale: Parse response.text and cast (or validate with a type guard) in one place, the API client. Everything above that layer then gets real types, with no any. Use throw: false so you can inspect status and headers (e.g. retry-after on 429) instead of catching an opaque exception.


Minimize Console Logging

Rule: Best practice from official guidelines

❌ INCORRECT:

async onload() {
  console.log('Plugin loaded');
  console.log('Processing file:', file.path);
  console.log('Settings updated:', settings);
}

onunload() {
  console.log('Plugin unloaded');
}

✅ CORRECT:

async onload() {
  // Only log errors by default
  console.error('Failed to process file:', error);

  // Use debug mode for development logging
  if (this.settings.debugMode) {
    console.log('Processing file:', file.path);
  }
}

onunload() {
  // No console.log in production
}

Rationale: The developer console should display errors by default, not debug messages. Minimize unnecessary console output. In production, do not use console.log in onload and onunload - these methods are called frequently and pollute the console. Use debug mode flags for development logging.


Prefer AbstractInputSuggest

Rule: obsidianmd/prefer-abstract-input-suggest

❌ INCORRECT:

// Don't use the custom TextInputSuggest implementation
// (frequently copied from Liam's code)
class MyTextInputSuggest extends TextInputSuggest<string> {
  // Uses createPopper with sameWidth modifier
}

✅ CORRECT:

import { AbstractInputSuggest } from 'obsidian';

class MyInputSuggest extends AbstractInputSuggest<string> {
  getSuggestions(query: string): string[] {
    // Return suggestions
  }

  renderSuggestion(value: string, el: HTMLElement) {
    el.setText(value);
  }

  selectSuggestion(value: string, evt: MouseEvent | KeyboardEvent) {
    // Handle selection
  }
}

Rationale: Use the built-in AbstractInputSuggest API instead of copying custom TextInputSuggest implementations that use createPopper.


Use updateOptions() for Editor Extensions

Rule: Official guidelines

// When reconfiguring editor extensions
this.app.workspace.updateOptions();

Rationale: When reconfiguring editor extensions, use updateOptions() to flush changes across all open editors.


Target Main Workspace from Settings (v1.13.0+)

Rule: Multi-window compatibility

As of Obsidian 1.13.0, settings open in a new window instead of a modal. Code using activeDocument from settings callbacks will get the settings window's document, not the main vault window.

❌ INCORRECT (breaks in 1.13.0+):

// In settings tab or settings callback
updateMainUI() {
  // activeDocument points to settings window, not main vault!
  const container = activeDocument.querySelector('.nav-files-container');
  container?.addClass('my-plugin-active');
}

✅ CORRECT (works in all versions):

// In settings tab or settings callback
updateMainUI() {
  // Always targets the main workspace window
  const doc = this.app.workspace.containerEl.ownerDocument;
  const container = doc.querySelector('.nav-files-container');
  container?.addClass('my-plugin-active');
}

When to use which:

  • Main workspace UI (nav, sidebar, workspace elements): Use this.app.workspace.containerEl.ownerDocument
  • Same-window UI (modal contents, settings elements): activeDocument or this.containerEl.ownerDocument both work

Rationale: workspace.containerEl.ownerDocument always points to the main vault window regardless of which window triggered the call. This is backwards compatible with older Obsidian versions.


Async/Await Patterns

Prefer async/await over Promise chains

Rule: Code readability and maintainability

❌ INCORRECT:

function loadData() {
  return new Promise((resolve) => {
    setTimeout(() => resolve(data), 1000);
  });
}

getData()
  .then(result => processResult(result))
  .then(processed => saveData(processed))
  .catch(error => console.error(error))
  .finally(() => cleanup());

✅ CORRECT:

async function loadData() {
  await sleep(1000);  // Use Obsidian's sleep() helper
  return data;
}

try {
  const result = await getData();
  const processed = await processResult(result);
  await saveData(processed);
} catch (error) {
  console.error(error);
} finally {
  cleanup();
}

Rationale: async/await is more readable and maintainable. Use Obsidian's sleep() function instead of new Promise with setTimeout.


DOM Helpers

Use Obsidian DOM Helpers

Rule: Prefer Obsidian API over vanilla DOM

❌ INCORRECT:

const div = document.createElement('div');
const span = document.createElement('span');
const fragment = document.createDocumentFragment();

✅ CORRECT:

// On any HTMLElement:
const div = containerEl.createDiv();
const span = containerEl.createSpan();
const el = containerEl.createEl('section');

// Or use global helpers:
const div = createDiv();
const span = createSpan();
const fragment = createFragment();

// activeDocument → activeWindow (v0.4.1+ autofix):
activeWindow.createEl('p');        // not activeDocument.createEl('p')
activeWindow.createDiv();
activeWindow.createFragment();

// Document-typed variables use .win:
const doc = this.app.workspace.containerEl.ownerDocument;
doc.win.createEl('p');             // not doc.createEl('p')

Rationale: Obsidian's helper functions (createDiv(), createSpan(), createEl(), createFragment()) are more concise and integrate better with the API. activeDocument is a Document and doesn't have these helpers — they live on Window/activeWindow. For Document-typed variables, access helpers via the .win property.


Deprecated & Replaceable Packages

The Obsidian community plugin scanner checks for npm packages that have been superseded by Node.js built-ins. Using flagged packages produces warnings that lower your Scorecard score.

Replace builtin-modules with node:module

Rule: Scanner warning — deprecated package

The builtin-modules package provides a list of Node.js built-in module names. Node.js has shipped this natively as module.builtinModules since v9.3.0.

❌ INCORRECT (esbuild.config.mjs):

import builtins from "builtin-modules";
// ...
external: [...builtins],

✅ CORRECT (esbuild.config.mjs):

import { builtinModules } from "node:module";
// ...
external: [...builtinModules],

Then remove builtin-modules from package.json devDependencies.

General Guidance

When the scanner flags a package with a "should be replaced with an alternative" warning:

  1. Check es-tooling/module-replacements for the recommended replacement
  2. Replace the import with the Node.js built-in or recommended alternative
  3. Remove the flagged package from package.json
  4. Verify the build still passes

Obsidian API Deprecations

ButtonComponent.setWarning() → setDestructive()

As of Obsidian 1.13+, ButtonComponent.setWarning() is deprecated. Use setDestructive() instead. For destructive primary actions, chain .setDestructive().setCta().

❌ INCORRECT:

new Setting(containerEl)
  .addButton(btn => btn.setWarning().setButtonText('Delete'));

✅ CORRECT:

new Setting(containerEl)
  .addButton(btn => btn.setDestructive().setButtonText('Delete'));

Scope: only ButtonComponent.setWarning() is deprecated. MenuItem.setWarning(isWarning: boolean) is a different, non-deprecated method that takes an argument — don't "fix" it to setDestructive().

PluginSettingTab.display() → getSettingDefinitions()

display() carries @deprecated Since 1.13.0 in the 1.13 typings, so @typescript-eslint/no-deprecated flags every implementation of it — including plugins that legitimately still need it to support Obsidian below 1.13.0 (see Path B).

You cannot silence this inline. @typescript-eslint/no-deprecated is on the bundled eslint-comments/no-restricted-disable list, so an eslint-disable-next-line for it is itself an error — see Disabling rule X is not allowed. Use a file-scoped override in eslint.config.mjs instead:

{
  // Path B: minAppVersion is below 1.13.0, so display() is still the render path.
  files: ["src/settings-tab.ts"],
  rules: { "@typescript-eslint/no-deprecated": "off" },
}

Scope it to the one file, and delete the override the moment you bump minAppVersion to 1.13.0 — otherwise it hides every other deprecation in that file. On Path A the warning is correct and the fix is to delete display().

Note: These deprecations only surface when the obsidian typings are current — stale typings silently hide them. Keep "obsidian": "latest" in devDependencies (it's a compile-time-only dependency; the runtime API is provided by the app).

Typings lag the app: the obsidian npm package tracks the API surface, not the release train — it can sit a patch or two behind the public desktop build. A 1.13.x app version doesn't guarantee a matching npm version exists.


Miscellaneous Rules

Don't Mutate Defaults with Object.assign

Rule: obsidianmd/object-assign

Avoid calling Object.assign with exactly two arguments where the first argument is a variable with "default" in its name. This mutates the defaults object unexpectedly.

❌ INCORRECT:

// Mutates DEFAULT_SETTINGS - subsequent calls see modified defaults!
Object.assign(DEFAULT_SETTINGS, userSettings);

✅ CORRECT:

// Merge into a fresh object - defaults stay intact
Object.assign({}, DEFAULT_SETTINGS, userSettings);

// Or use spread syntax (preferred):
const settings = { ...DEFAULT_SETTINGS, ...userSettings };

Rationale: When the target of Object.assign is a variable holding default values (name contains "default"), the defaults object itself is mutated. Pass an empty {} as the first argument or use spread syntax to avoid unexpected side-effects.


Organize Multi-File Plugins into Folders

Rule: Best practice from official guidelines

✅ GOOD STRUCTURE:

my-plugin/
├── src/
│   ├── commands/
│   ├── modals/
│   ├── settings/
│   ├── utils/
│   └── main.ts
├── styles.css
├── manifest.json
└── README.md

Rationale: For plugins with multiple files, organize them into folders to improve maintainability and review processes.


Validate manifest.json

Rule: obsidianmd/validate-manifest

Ensure your manifest.json is valid:

{
  "id": "unique-plugin-id",
  "name": "Plugin Name",
  "version": "1.0.0",
  "minAppVersion": "0.15.0",
  "description": "Short description",
  "author": "Your Name",
  "authorUrl": "https://...",
  "isDesktopOnly": false
}

Validate LICENSE

Rule: obsidianmd/validate-license

Your plugin must include a valid LICENSE file. The rule checks two things:

  1. Copyright holder: The holder must not be "Dynalist Inc." (the Obsidian sample plugin's default). Change it to your name.
  2. Copyright year: The year must be current. Update it when the year changes.

❌ INCORRECT (unchanged sample plugin LICENSE):

Copyright (C) 2021 by Dynalist Inc.

✅ CORRECT:

Copyright (C) 2024 by Your Name

Rule options:

"obsidianmd/validate-license": ["error", {
  currentYear: 2026,           // Override the year check (defaults to current year)
  disableUnchangedYear: false, // Set true to skip year validation
}]

Rationale: The Obsidian sample plugin ships with Dynalist Inc. as copyright holder and an old year. Forgetting to update this before submission is a common mistake.

Source: SKILL.md on GitHub

No alerts4d5 checks · Risk SAFE
  • Gen Agent Trust Hub6d

    The skill is a comprehensive, high-quality documentation resource for Obsidian.md plugin development. It provides correct security guidance (such as XSS prevention), memory management practices, and accessibility standards. No malicious code, data exfiltration, or obfuscation was detected.

  • Socket4d

    No alerts

  • Snyk6d

    Risk: LOW · No issues

  • Runlayer7mo

    9 files scanned · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at ff97cb0. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last week.

Activeupdated last week
metadata
{
  "version": "1.11.1"
}

README badge

README badge for gapmiss/obsidian-plugin-skill