Site de Emmanuel Demey

Building Your First Obsidian Plugin: A Word Counter Badge (Part 2)

Enhance your Obsidian plugin with settings, commands, and advanced features. Learn packaging, distribution, and best practices for production-ready plugins.

In Part 1, we built a basic word counter plugin for Obsidian. Now we’ll add professional features: customizable settings, reading time estimation, detailed statistics commands, and prepare for distribution.

Adding Settings

Create a settings interface to let users customize what the plugin displays:

interface WordCounterSettings {
  showWords: boolean;
  showCharacters: boolean;
  showReadingTime: boolean;
}

const DEFAULT_SETTINGS: WordCounterSettings = {
  showWords: true,
  showCharacters: true,
  showReadingTime: false,
};

Add settings to the plugin class:

export default class WordCounterPlugin extends Plugin {
  settings: WordCounterSettings;

  async onload() {
    await this.loadSettings();

    // Add settings tab
    this.addSettingTab(new WordCounterSettingTab(this.app, this));

    // ... rest of your code
  }

  async loadSettings() {
    this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
  }

  async saveSettings() {
    await this.saveData(this.settings);
  }
}

Create the settings tab:

import { App, PluginSettingTab, Setting } from "obsidian";

class WordCounterSettingTab extends PluginSettingTab {
  plugin: WordCounterPlugin;

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

  display(): void {
    const { containerEl } = this;
    containerEl.empty();

    new Setting(containerEl)
      .setName("Show word count")
      .setDesc("Display word count in status bar")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.showWords)
          .onChange(async (value) => {
            this.plugin.settings.showWords = value;
            await this.plugin.saveSettings();
            this.plugin.updateWordCount();
          }),
      );

    new Setting(containerEl)
      .setName("Show character count")
      .setDesc("Display character count in status bar")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.showCharacters)
          .onChange(async (value) => {
            this.plugin.settings.showCharacters = value;
            await this.plugin.saveSettings();
            this.plugin.updateWordCount();
          }),
      );

    new Setting(containerEl)
      .setName("Show reading time")
      .setDesc("Display estimated reading time (assuming 200 words/minute)")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.showReadingTime)
          .onChange(async (value) => {
            this.plugin.settings.showReadingTime = value;
            await this.plugin.saveSettings();
            this.plugin.updateWordCount();
          }),
      );
  }
}

Adding Reading Time

Update the calculateStats method:

private calculateStats(text: string): {
  words: number;
  characters: number;
  readingTime: number;
} {
  // ... existing code ...

  // Calculate reading time (assuming 200 words per minute)
  const readingTime = Math.ceil(words / 200);

  return { words, characters, readingTime };
}

Update the display logic:

private updateWordCount() {
  const view = this.app.workspace.getActiveViewOfType(MarkdownView);

  if (!view) {
    this.statusBarItem.setText('');
    return;
  }

  const content = view.editor.getValue();
  const stats = this.calculateStats(content);

  const parts: string[] = [];

  if (this.settings.showWords) {
    parts.push(`Words: ${stats.words}`);
  }

  if (this.settings.showCharacters) {
    parts.push(`Chars: ${stats.characters}`);
  }

  if (this.settings.showReadingTime) {
    parts.push(`Read: ${stats.readingTime}m`);
  }

  this.statusBarItem.setText(parts.join(' | '));
}

Adding Commands

Add a command to show detailed statistics:

async onload() {
  // ... existing code ...

  this.addCommand({
    id: 'show-word-count-modal',
    name: 'Show detailed word count',
    callback: () => {
      this.showDetailedStats();
    },
  });
}

private showDetailedStats() {
  const view = this.app.workspace.getActiveViewOfType(MarkdownView);

  if (!view) {
    return;
  }

  const content = view.editor.getValue();
  const stats = this.calculateStats(content);

  const modal = new Modal(this.app);
  modal.titleEl.setText('Document Statistics');

  const contentEl = modal.contentEl;
  contentEl.createEl('p', { text: `Words: ${stats.words}` });
  contentEl.createEl('p', { text: `Characters (no spaces): ${stats.characters}` });
  contentEl.createEl('p', { text: `Characters (with spaces): ${content.length}` });
  contentEl.createEl('p', { text: `Reading time: ${stats.readingTime} minutes` });
  contentEl.createEl('p', { text: `Lines: ${content.split('\n').length}` });

  modal.open();
}

Import Modal:

import { Plugin, MarkdownView, Modal } from "obsidian";

Complete Enhanced Plugin Code

Here’s the full enhanced version with all features:

import {
  App,
  Plugin,
  PluginSettingTab,
  Setting,
  MarkdownView,
  Modal,
} from "obsidian";

interface WordCounterSettings {
  showWords: boolean;
  showCharacters: boolean;
  showReadingTime: boolean;
}

const DEFAULT_SETTINGS: WordCounterSettings = {
  showWords: true,
  showCharacters: true,
  showReadingTime: false,
};

export default class WordCounterPlugin extends Plugin {
  settings: WordCounterSettings;
  private statusBarItem: HTMLElement;

  async onload() {
    console.log("Loading Word Counter Badge plugin");

    await this.loadSettings();

    // Create status bar item
    this.statusBarItem = this.addStatusBarItem();
    this.statusBarItem.addClass("word-counter-badge");

    // Update counter when active note changes
    this.registerEvent(
      this.app.workspace.on("active-leaf-change", () => {
        this.updateWordCount();
      }),
    );

    // Update counter when file content changes
    this.registerEvent(
      this.app.workspace.on("editor-change", () => {
        this.updateWordCount();
      }),
    );

    // Add command for detailed stats
    this.addCommand({
      id: "show-word-count-modal",
      name: "Show detailed word count",
      callback: () => {
        this.showDetailedStats();
      },
    });

    // Add settings tab
    this.addSettingTab(new WordCounterSettingTab(this.app, this));

    // Initial update
    this.updateWordCount();
  }

  onunload() {
    console.log("Unloading Word Counter Badge plugin");
  }

  async loadSettings() {
    this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
  }

  async saveSettings() {
    await this.saveData(this.settings);
  }

  updateWordCount() {
    const view = this.app.workspace.getActiveViewOfType(MarkdownView);

    if (!view) {
      this.statusBarItem.setText("");
      return;
    }

    const content = view.editor.getValue();
    const stats = this.calculateStats(content);

    const parts: string[] = [];

    if (this.settings.showWords) {
      parts.push(`Words: ${stats.words}`);
    }

    if (this.settings.showCharacters) {
      parts.push(`Chars: ${stats.characters}`);
    }

    if (this.settings.showReadingTime && stats.words > 0) {
      parts.push(`Read: ${stats.readingTime}m`);
    }

    this.statusBarItem.setText(parts.join(" | "));
  }

  private calculateStats(text: string): {
    words: number;
    characters: number;
    readingTime: number;
  } {
    // Remove markdown syntax for more accurate counting
    const cleanText = text
      .replace(/```[\s\S]*?```/g, "")
      .replace(/`[^`]*`/g, "")
      .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
      .replace(/!\[([^\]]*)\]\([^\)]+\)/g, "")
      .replace(/[*_]{1,3}/g, "")
      .replace(/^#{1,6}\s+/gm, "")
      .replace(/^>\s+/gm, "");

    const words = cleanText
      .trim()
      .split(/\s+/)
      .filter((word) => word.length > 0).length;

    const characters = cleanText.replace(/\s/g, "").length;
    const readingTime = Math.ceil(words / 200);

    return { words, characters, readingTime };
  }

  private showDetailedStats() {
    const view = this.app.workspace.getActiveViewOfType(MarkdownView);

    if (!view) {
      return;
    }

    const content = view.editor.getValue();
    const stats = this.calculateStats(content);

    const modal = new Modal(this.app);
    modal.titleEl.setText("Document Statistics");

    const contentEl = modal.contentEl;
    contentEl.createEl("h3", { text: "Current Document" });
    contentEl.createEl("p", { text: `Words: ${stats.words}` });
    contentEl.createEl("p", {
      text: `Characters (no spaces): ${stats.characters}`,
    });
    contentEl.createEl("p", {
      text: `Characters (with spaces): ${content.length}`,
    });
    contentEl.createEl("p", {
      text: `Reading time: ${stats.readingTime} minute${stats.readingTime !== 1 ? "s" : ""}`,
    });
    contentEl.createEl("p", { text: `Lines: ${content.split("\n").length}` });
    contentEl.createEl("p", {
      text: `Paragraphs: ${content.split(/\n\n+/).filter((p) => p.trim()).length}`,
    });

    modal.open();
  }
}

class WordCounterSettingTab extends PluginSettingTab {
  plugin: WordCounterPlugin;

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

  display(): void {
    const { containerEl } = this;
    containerEl.empty();

    containerEl.createEl("h2", { text: "Word Counter Badge Settings" });

    new Setting(containerEl)
      .setName("Show word count")
      .setDesc("Display word count in status bar")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.showWords)
          .onChange(async (value) => {
            this.plugin.settings.showWords = value;
            await this.plugin.saveSettings();
            this.plugin.updateWordCount();
          }),
      );

    new Setting(containerEl)
      .setName("Show character count")
      .setDesc("Display character count (without spaces) in status bar")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.showCharacters)
          .onChange(async (value) => {
            this.plugin.settings.showCharacters = value;
            await this.plugin.saveSettings();
            this.plugin.updateWordCount();
          }),
      );

    new Setting(containerEl)
      .setName("Show reading time")
      .setDesc("Display estimated reading time (assuming 200 words per minute)")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.showReadingTime)
          .onChange(async (value) => {
            this.plugin.settings.showReadingTime = value;
            await this.plugin.saveSettings();
            this.plugin.updateWordCount();
          }),
      );
  }
}

Best Practices

Performance Optimization

For large documents, consider debouncing the editor-change event:

private debounceTimer: NodeJS.Timeout | null = null;

this.registerEvent(
  this.app.workspace.on('editor-change', () => {
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }
    this.debounceTimer = setTimeout(() => {
      this.updateWordCount();
    }, 300);
  })
);

Error Handling

Always check if views exist before accessing them:

const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
  return;
}

Memory Management

Use registerEvent() instead of manually adding event listeners. This ensures proper cleanup when the plugin is disabled.

Testing Checklist

Before releasing, test:

  • Opening/closing notes
  • Switching between notes
  • Editing content
  • Empty notes
  • Very large notes (10,000+ words)
  • Notes with heavy Markdown formatting
  • Settings persistence across restarts
  • Command palette integration
  • Mobile compatibility (if applicable)

Packaging and Distribution

Building for Production

npm run build

This creates an optimized main.js without source maps.

Preparing for Release

Create a versions.json file:

{
  "1.0.0": "0.15.0"
}

This maps your plugin version to the minimum required Obsidian version.

Manual Installation

Users can manually install by:

  1. Downloading main.js, manifest.json, and styles.css (if you have one)
  2. Creating a folder in .obsidian/plugins/word-counter-badge
  3. Placing the files there
  4. Reloading Obsidian

Submitting to Community Plugins

To submit your plugin to Obsidian’s community plugin directory:

  1. Create a GitHub repository with your plugin code
  2. Create a release (tag it with your version number)
  3. Attach main.js and manifest.json to the release
  4. Fork the obsidian-releases repository
  5. Add your plugin to community-plugins.json
  6. Submit a pull request

The Obsidian team will review your submission.

Troubleshooting Common Issues

Plugin Doesn’t Load

Check:

  • Is the folder name correct?
  • Is manifest.json valid JSON?
  • Are there TypeScript errors?
  • Check the developer console for errors

Status Bar Doesn’t Update

Check:

  • Are events properly registered?
  • Is updateWordCount() being called?
  • Add console.log statements to debug

Incorrect Word Counts

Check:

  • Is the regex correctly stripping Markdown?
  • Test with edge cases (empty strings, special characters)
  • Add unit tests for calculateStats()

Next Steps

Now that you’ve built your first plugin, consider:

  1. Add styling: Create a styles.css to customize the appearance
  2. Internationalization: Support multiple languages
  3. Advanced features:
    • Word frequency analysis
    • Vocabulary richness metrics
    • Export statistics to CSV
  4. Integration: Sync with external services (writing goal trackers)

Resources

Conclusion

You’ve built a fully functional Obsidian plugin that:

  • Displays real-time word counts
  • Includes customizable settings
  • Provides detailed statistics via a modal
  • Follows Obsidian’s best practices

The key takeaways from this two-part tutorial:

  1. Plugin lifecycle: Understanding onload() and onunload()
  2. Event system: Reacting to workspace changes
  3. UI integration: Status bar, modals, and settings tabs
  4. TypeScript skills: Type safety and modern JavaScript features
  5. Best practices: Performance, error handling, and memory management

The Obsidian plugin ecosystem thrives on community contributions. Consider sharing your plugin with others or extending it with new features!

Happy coding, and welcome to the world of Obsidian plugin development!