Files
zeroq-qol-modules/review.md
T
2026-07-11 14:56:35 +03:00

6.2 KiB

Project Review: zeroq-qol-modules

Overview

Obsidian plugin with a modular architecture. Each feature is a separate module that can be toggled on/off in settings.

Plugin ID: zeroq-qol-modules
Entry point: src/main.ts → compiles to main.js (esbuild)
Version source: package.json → auto-synced to manifest.json on build
Current modules (2): attachment-clean-paste, preserve-link-aliases

Stack

  • Language: TypeScript (5.x)
  • Bundler: esbuild (0.17.x)
  • Obsidian API: obsidian latest (type defs only, excluded from bundle)
  • Format: CommonJS (format: 'cjs'), target ES2018

Directory Structure

src/
├── main.ts                          # Plugin class — loads settings, bootstraps modules
├── settings.ts                      # ZeroQSettingTab — single settings pane, renders all modules
├── types.ts                         # QoLModule interface, ZeroQSettings, ModuleSettings
├── locales/
│   ├── en.ts                        # Locale type definition + English dictionary
│   ├── ru.ts                        # Russian dictionary (same Locale shape)
│   └── index.ts                     # Auto-detects language from <html lang>, exports locale
└── modules/
    ├── index.ts                     # Static registry: array of all module instances
    ├── base-module.ts               # BaseModule abstract class
    ├── attachment-clean-paste/
    │   └── index.ts                 # Handles paste/drop — replaces ![[embed]] → [[path|name]]
    └── preserve-link-aliases/
        └── index.ts                 # Preserves aliases on vault rename events

Architecture

Plugin Lifecycle (main.ts)

onload()
  → loadSettings()          # merge DEFAULT_SETTINGS + saved data
  → addSettingTab()         # register ZeroQSettingTab
  → for each module:
      if enabled → loadModule(module)
                       → module.onload(this, moduleSettings)

onunload()
  → for each module:
      unloadModule(module)
        → module.onunload(this)

Module Lifecycle

Each module is a singleton implementing QoLModule (or extending BaseModule):

Method Purpose
onload(plugin, moduleSettings) Initialize: register events, commands, setting tab
onunload(plugin) Cleanup (auto-runs registerCleanup callbacks)
renderInlineSettings(containerEl, settings, saveSettings) Render module-specific settings inline (no separate tab)
defaultSettings Default module settings object

Cleanup pattern

Modules use this.registerCleanup(fn) to queue cleanup callbacks. They auto-run in onunload(). Example:

const ref = plugin.app.workspace.on('editor-paste', handler);
this.registerCleanup(() => plugin.app.workspace.offref(ref));

Settings architecture

  • ZeroQSettingTab (settings.ts) — single Obsidian settings pane
  • Iterates MODULES, renders each with:
    1. Toggle (enable/disable) — toggling calls loadModule / unloadModule at runtime
    2. module.renderInlineSettings() — module-specific controls rendered directly below the toggle
  • No separate PluginSettingTab per module — all in one block

Settings data shape

interface ZeroQSettings {
  modules: Record<string, {
    enabled: boolean;
    settings: Record<string, unknown>;  // module-specific, typed per module
  }>;
}

Localization (locales/)

  • Locale type defined in en.ts as the interface Locale
  • Language detection: reads <html lang> attribute (set by Obsidian per its own language setting)
  • Fallback to 'en' if language is not in the dictionary
  • Usage: import { locale } from '../../locales'locale.settings.title, locale.modules['xxx'].name

To add a language:

  1. Create src/locales/de.ts with Locale shape
  2. Import it in src/locales/index.ts and add to locales record

Adding a New Module

Step 1: Create the module class

Extend BaseModule in src/modules/my-module/index.ts:

import { Plugin } from 'obsidian';
import { BaseModule } from '../base-module';
import { locale } from '../../locales';

export class MyModule extends BaseModule {
  id = 'my-module';
  name = locale.modules['my-module'].name;
  description = locale.modules['my-module'].description;

  get defaultSettings(): Record<string, unknown> {
    return { myOption: 'default' };
  }

  onload(plugin: Plugin, moduleSettings: Record<string, unknown>): void {
    // register events/commands via plugin.registerEvent / plugin.addCommand
    // use this.registerCleanup(fn) for teardown
  }

  renderInlineSettings(
    containerEl: HTMLElement,
    settings: Record<string, unknown>,
    saveSettings: () => Promise<void>,
  ): void {
    // render controls into containerEl
    // mutate settings object directly, then call saveSettings()
    new Setting(containerEl)
      .setName('My option')
      .addText(text => text
        .setValue(String(settings.myOption ?? ''))
        .onChange(async v => {
          settings.myOption = v;
          await saveSettings();
        }));
  }
}

Step 2: Add translations

In src/locales/en.ts, add to the Locale interface and the en object:

'modules': {
  'my-module': {
    name: string;
    description: string;
    // any other strings this module needs
  };
}

Do the same in src/locales/ru.ts.

Step 3: Register in the registry

In src/modules/index.ts:

import { MyModule } from './my-module';

export const MODULES: QoLModule[] = [
  new AttachmentCleanPasteModule(),
  new MyModule(),
];

Build

npm run build    # production build (minified, no sourcemaps)
npm run dev      # dev build (inline sourcemaps)

esbuild.config.mjs also syncs version from package.json to manifest.json before building.

Conventions

  • No comments in source code
  • No emojis in code or UI
  • Indentation: tabs (Obsidian convention)
  • Imports: import type for type-only imports to avoid circular dependencies
  • File naming: kebab-case for files, PascalCase for classes
  • Russian labels for Russian locale, English labels for all other locales
  • Module id is kebab-case and matches the key in the locales modules object