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:
- Downloading
main.js,manifest.json, andstyles.css(if you have one) - Creating a folder in
.obsidian/plugins/word-counter-badge - Placing the files there
- Reloading Obsidian
Submitting to Community Plugins
To submit your plugin to Obsidian’s community plugin directory:
- Create a GitHub repository with your plugin code
- Create a release (tag it with your version number)
- Attach
main.jsandmanifest.jsonto the release - Fork the obsidian-releases repository
- Add your plugin to
community-plugins.json - 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.jsonvalid 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:
- Add styling: Create a
styles.cssto customize the appearance - Internationalization: Support multiple languages
- Advanced features:
- Word frequency analysis
- Vocabulary richness metrics
- Export statistics to CSV
- Integration: Sync with external services (writing goal trackers)
Resources
- Obsidian API Documentation
- Plugin Developer Docs
- Sample Plugin Repository
- Obsidian Community Plugins
- TypeScript Handbook
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:
- Plugin lifecycle: Understanding
onload()andonunload() - Event system: Reacting to workspace changes
- UI integration: Status bar, modals, and settings tabs
- TypeScript skills: Type safety and modern JavaScript features
- 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!