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.

referenceui-ux.md

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

UI/UX Standards

Consistent UI/UX is essential for a native-feeling Obsidian plugin experience.

Table of Contents


Sentence Case for UI Text

Enforce Sentence Case for UI Text

Rule: obsidianmd/ui/sentence-case (auto-fixable)

Note (v0.4.0): This rule is now enabled (warn) in the recommended config with { enforceCamelCaseLower: true } — it was disabled in v0.3.0.

Use sentence case (first word capitalized, rest lowercase except proper nouns) for all UI text.

❌ INCORRECT:

.setName('Advanced Settings')
.setDesc('Configure Advanced Options')
.setButtonText('Save Changes')
new Notice('File Successfully Saved')

✅ CORRECT:

.setName('Advanced settings')
.setDesc('Configure advanced options')
.setButtonText('Save changes')
new Notice('File successfully saved')

Configuration options:

'obsidianmd/ui/sentence-case': ['warn', {
  brands: ['Obsidian', 'GitHub'],      // Preserve brand names
  acronyms: ['API', 'URL', 'HTML'],    // Preserve acronyms
  enforceCamelCaseLower: true,         // Fix camelCase to sentence case
}]

Applies to:

  • .setName(), .setDesc(), .setText(), .setTitle()
  • .setButtonText(), .setPlaceholder(), .setTooltip()
  • createEl() text and attributes
  • new Notice() messages
  • addCommand() names
  • .setAttribute() for aria-label, aria-description, title, placeholder
  • textContent, innerText assignments

Handling Edge Cases: Plugin Names and Acronyms

The sentence-case rule may flag text that intentionally contains uppercase words — plugin names, brand names, or acronyms. The rule is advisory and there are always edge cases, but you can whitelist known terms via acronyms and brands options instead of suppressing the rule entirely.

❌ INCORRECT — suppressing the rule:

// eslint-disable-next-line obsidianmd/ui/sentence-case
.setName('Open TTRPG tools - controls')

✅ CORRECT — configure the rule with your project's terms:

'obsidianmd/ui/sentence-case': ['warn', {
  acronyms: ['TTRPG', 'API', 'URL', 'HTML'],
  brands: [
    'Style Settings',
    'TTRPG Tools - Controls',
    'TTRPG Tools',
  ],
}]
  • acronyms — uppercase abbreviations that should stay uppercase (e.g., API, URL, TTRPG)
  • brands — proper names that should preserve their exact casing (e.g., plugin names like Style Settings, Dataview)

When a plugin references other plugins or well-known tools in its UI text, add those names to brands rather than rewriting them to sentence case.


Sentence Case for Locale Files

Plugins that externalize UI strings into locale files must also enforce sentence case.

Sentence Case for JSON Locale Files

Rule: obsidianmd/ui/sentence-case-json (auto-fixable)

Enforces sentence case for English locale strings stored in JSON files (e.g. en.json, locales/en.json).

❌ INCORRECT (en.json):

{
  "openSettings": "Open Settings",
  "saveChanges": "Save Changes",
  "deleteFile": "Delete File"
}

✅ CORRECT (en.json):

{
  "openSettings": "Open settings",
  "saveChanges": "Save changes",
  "deleteFile": "Delete file"
}

Sentence Case for TypeScript/JavaScript Locale Modules

Rule: obsidianmd/ui/sentence-case-locale-module (auto-fixable)

Enforces sentence case for English locale strings exported from TS/JS modules (e.g. en.ts, locales/en.js).

❌ INCORRECT (en.ts):

export default {
  openSettings: "Open Settings",
  saveChanges: "Save Changes",
};

✅ CORRECT (en.ts):

export default {
  openSettings: "Open settings",
  saveChanges: "Save changes",
};

Enable Locale Checks with recommendedWithLocalesEn

To enable all three sentence-case rules (TypeScript sources + JSON + TS/JS locale modules), use the recommendedWithLocalesEn config instead of recommended:

// eslint.config.mjs
import obsidianmd from "eslint-plugin-obsidianmd";

export default [
  // Use this config to also lint en*.json, en*.ts, en*.js locale files
  ...obsidianmd.configs.recommendedWithLocalesEn,
];

The recommended config only checks ui/sentence-case (inline TypeScript strings). The recommendedWithLocalesEn config additionally enables:

  • ui/sentence-case-json — checks en*.json and en/**/*.json
  • ui/sentence-case-locale-module — checks en*.ts, en*.js, and en/**/*

Command Naming Conventions

No Redundant "Command" in Names

Rules:

  • obsidianmd/commands/no-command-in-command-id
  • obsidianmd/commands/no-command-in-command-name

❌ INCORRECT:

this.addCommand({
  id: 'open-settings-command',
  name: 'Open settings command',
});

✅ CORRECT:

this.addCommand({
  id: 'open-settings',
  name: 'Open settings',
});

No Plugin ID/Name in Command IDs

Rules:

  • obsidianmd/commands/no-plugin-id-in-command-id
  • obsidianmd/commands/no-plugin-name-in-command-name

❌ INCORRECT:

// If plugin id is "my-plugin"
this.addCommand({
  id: 'my-plugin-open-settings',
  name: 'My Plugin: Open settings',
});

✅ CORRECT:

this.addCommand({
  id: 'open-settings',
  name: 'Open settings',
});

Rationale: Obsidian automatically namespaces commands with the plugin ID.


No Default Hotkeys

Rule: obsidianmd/commands/no-default-hotkeys

❌ INCORRECT:

this.addCommand({
  id: 'toggle-feature',
  name: 'Toggle feature',
  hotkeys: [{ modifiers: ['Mod'], key: 't' }],  // Don't set defaults
});

✅ CORRECT:

this.addCommand({
  id: 'toggle-feature',
  name: 'Toggle feature',
  // Let users configure their own hotkeys
});

Rationale: Avoid hotkey conflicts. Let users choose their own shortcuts.


Use Appropriate Command Callbacks

Rule: Official guidelines

Choose the right callback type for your commands:

// callback: Always executes
this.addCommand({
  id: 'show-info',
  name: 'Show info',
  callback: () => {
    new Notice('Always works!');
  }
});

// checkCallback: Conditional execution (returns true if executed)
this.addCommand({
  id: 'format-selection',
  name: 'Format selection',
  checkCallback: (checking: boolean) => {
    const view = this.app.workspace.getActiveViewOfType(MarkdownView);
    if (view) {
      if (!checking) {
        // Perform the action
        const editor = view.editor;
        const selection = editor.getSelection();
        editor.replaceSelection(selection.toUpperCase());
      }
      return true;
    }
    return false;
  }
});

// editorCallback: Only available when editor is active
this.addCommand({
  id: 'insert-timestamp',
  name: 'Insert timestamp',
  editorCallback: (editor: Editor, view: MarkdownView) => {
    editor.replaceSelection(new Date().toISOString());
  }
});

Rationale:

  • Use callback for unconditional execution
  • Use checkCallback for conditional execution (command only shows when available)
  • Use editorCallback for editor-dependent commands

Settings & Configuration

No Manual HTML Headings in Settings

Rule: obsidianmd/settings-tab/no-manual-html-headings

❌ INCORRECT:

containerEl.createEl('h3', { text: 'Appearance' });

✅ CORRECT:

new Setting(containerEl).setName('Appearance').setHeading();

Rationale: Use Obsidian's built-in heading API for consistency.


No Problematic Settings Headings

Rule: obsidianmd/settings-tab/no-problematic-settings-headings (auto-fixable)

❌ INCORRECT:

new Setting(containerEl)
  .setName('General settings')  // Don't use "General"
  .setHeading();

new Setting(containerEl)
  .setName('Plugin options')  // Don't use "settings" or "options"
  .setHeading();

new Setting(containerEl)
  .setName('My Plugin preferences')  // Don't include plugin name
  .setHeading();

✅ CORRECT:

new Setting(containerEl)
  .setName('Appearance')
  .setHeading();

new Setting(containerEl)
  .setName('Behavior')
  .setHeading();

new Setting(containerEl)
  .setName('Advanced')
  .setHeading();

Rationale: Avoid redundant words in settings headings:

  • Don't use "settings" or "options" (user already knows they're in settings)
  • Don't use generic "General" heading
  • Don't include the plugin name (already shown in settings tab title)

Declarative Settings (Obsidian 1.13+)

As of Obsidian 1.13.0, PluginSettingTab supports a declarative API: override getSettingDefinitions() to return an array of setting definitions. Obsidian handles rendering, persistence, validation, and search indexing. You describe the settings, not the DOM.

Migration Paths

Your minAppVersion Path What to do
>= 1.13.0 Path A (preferred) Implement getSettingDefinitions() only. Delete display().
< 1.13.0 Path B (dual support) Keep display() and add getSettingDefinitions() alongside it. Both must stay in sync.
< 1.13.0, no need for new features No change The API is opt-in. Leave the plugin as-is.

Path A: Clean 1.13-only Migration

  1. Bump minAppVersion to "1.13.0" in manifest.json.
  2. Override getSettingDefinitions() — return an array of definition objects.
  3. For each setting, write { name, desc, control: { type, key } }. The key maps to a property on this.plugin.settings. Obsidian reads, writes, and persists automatically.
  4. Move value-shape validation (regex, range, format) from onChange into a validate callback on the control.
  5. Delete display() and remove unused imports (typically Setting).
import { App, PluginSettingTab } from 'obsidian';

class MySettingTab extends PluginSettingTab {
  plugin: MyPlugin;

  constructor(app: App, plugin: MyPlugin) {
    super(app, plugin);
    this.plugin = plugin;
  }

  getSettingDefinitions() {
    return [
      {
        name: 'Enable feature',
        desc: 'Turns the feature on or off.',
        control: { type: 'toggle', key: 'enabled' },
      },
      {
        name: 'Mode',
        control: {
          type: 'dropdown',
          key: 'mode',
          defaultValue: 'fast',
          options: { fast: 'Fast', thorough: 'Thorough' },
        },
      },
      {
        name: 'Cache key',
        desc: 'Alphanumeric only.',
        control: {
          type: 'text',
          key: 'cacheKey',
          placeholder: 'default',
          validate: (value: string) =>
            /^[a-z0-9]*$/i.test(value.trim()) ? undefined : 'Use letters and digits only.',
        },
      },
    ];
  }
}

Path B: Dual Support

Keep display() as-is and add getSettingDefinitions() alongside it. On 1.13.0+, Obsidian calls getSettingDefinitions() and skips display(). On older versions, display() runs as before.

Warning: The two implementations must stay in sync. Every time you add or change a setting, update both. If the maintenance overhead isn't worth it, prefer Path A and bump minAppVersion.

Control Types

toggle, dropdown, text, textarea, number, slider, color, file, folder

Definition Kinds

control, render, and action on a definition are mutually exclusive. Definitions can also be empty (heading-only), groups, lists (addItem/onDelete/onReorder), and sub-pages.

When to Use render Instead of control

Use a render callback when control + validate isn't enough:

  • Side effects on change — call a method, update a status bar, refresh another view
  • Inverted or derived values — a toggle that drives a string config, a slider that drives a complex calculation
  • Custom suggesters — a command picker, tag picker, or anything using AbstractInputSuggest

For conditional visibility, use the visible predicate instead of render.

Shared Definition Fields

Every definition (control, render, action, or empty) accepts these alongside name:

Field Type Purpose
desc string | DocumentFragment Description. A fragment's textContent is what search indexes.
aliases string[] Extra search terms — synonyms users might type instead of name.
visible boolean | (() => boolean) Hides the item and excludes it from search for that render cycle.
searchable boolean | (() => boolean) Keeps the item rendered but out of global search.

Controls additionally accept disabled: boolean | (() => boolean), which greys out the control while leaving it visible — the right choice for "unavailable because X", where hiding the setting would just be confusing.

visible and disabled are re-evaluated on every render, so the function forms can reflect runtime state.

Refreshing: update() vs refreshDomState()

Two different costs — pick by what changed:

  • refreshDomState() — re-evaluates every visible and disabled predicate and applies the result to the existing DOM. No re-render. Use this from a render callback's onChange after mutating state that other settings' predicates read.
  • update() — re-runs getSettingDefinitions() and re-renders. Use when the structure changed: items added or removed, list rows reordered.

Reach for refreshDomState() first; a full update() to toggle one control's enabled state is wasted work and drops focus.

Data-Shape Gotcha

Auto-persist calls saveData(plugin.settings), so all persisted plugin data must live inside the settings object. Sibling keys stored via saveData() outside settings will be clobbered.

❌ INCORRECT:

// Sibling key — clobbered when declarative settings auto-save
await this.saveData({ ...this.settings, bookmarks: this.bookmarks });

✅ CORRECT:

// All persisted data under settings
interface MySettings {
  enabled: boolean;
  bookmarks: string[];
}

Escape hatch: if you genuinely can't co-locate the data — settings split across multiple files, or a key that must live outside settings for backward compatibility — override getControlValue(key) and setControlValue(key, value) on your tab. PluginSettingTab implements them against plugin.settings; override both together and each control's key routes through your storage instead.

class MySettingTab extends PluginSettingTab {
  getControlValue(key: string): unknown {
    if (key === 'apiToken') return this.plugin.secrets.apiToken;
    return super.getControlValue(key);
  }

  async setControlValue(key: string, value: unknown): Promise<void> {
    if (key === 'apiToken') {
      this.plugin.secrets.apiToken = value as string;
      await this.plugin.saveSecrets();
      return;
    }
    await super.setControlValue(key, value);
  }
}

Prefer reshaping your data over this. An override is a second persistence path to keep correct.

Pitfalls

  • getSettingDefinitions() runs on every update() AND once at registration for search indexing. Keep it cheap — no I/O, no network calls.
  • A render callback does not auto-save. Always await this.plugin.saveData(this.plugin.settings) after mutating settings.
  • validate is a UI gate — it shows inline errors but doesn't modify stored values. Re-validate stored data in loadSettings() for data saved by older plugin versions.
  • Page names must be unique among siblings at the same depth.
  • When an action callback depends on row position, use the index argument — don't capture index from an outer map (it goes stale after reorder/delete).
  • To refresh the tab after data changes, call this.update() — or this.refreshDomState() when only visible/disabled predicates changed. On 1.13.0+, display() is bypassed when getSettingDefinitions() returns a non-empty array.
  • validate also runs once on mount, so a stored value that's already invalid surfaces its error immediately — but the bad value stays in plugin.settings until the user fixes it.
  • desc accepts string or DocumentFragment. For rich descriptions with formatting or links, pass a DocumentFragment built with createFragment(...).

Relationship to Existing Rules

  • Heading rules (no-manual-html-headings, no-problematic-settings-headings) apply to legacy display() implementations
  • Sentence case still applies to name, desc, and options values in declarative definitions
  • Settings window opened in a new window since 1.13 — see Target Main Workspace from Settings

Source: SKILL.md on GitHub

No alerts4d5 checks · Risk SAFE
  • Gen Agent Trust Hub5d

    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

  • Snyk5d

    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