refactor!: migrate to obsidian-dev-utils v2 component architecture

- Delete PluginTypes.ts and remove generic from PluginBase
- Convert PluginSettingsManager to PluginSettingsComponent with DI params
- Use constructor registerComponent() pattern for settings and settings tab
- Replace onSaveSettings override with event subscription on settings component
- Rename all source files to kebab-case (Plugin.ts -> plugin.ts, etc.)
- Add vitest test infrastructure with jsdom environment and obsidian-test-mocks
- Add 14 tests covering settings defaults, component creation, and plugin construction
- Update tsconfig: remove allowJs, svelte types; add vitest.config.ts to include
- Add obsidian-test-mocks, sass-embedded, vitest, jsdom as dev dependencies

BREAKING CHANGE: Requires obsidian-dev-utils v2 (component architecture).
This commit is contained in:
Michael Naumov 2026-04-22 02:31:42 -06:00
parent ea3e30ce0c
commit f32bbb7b58
23 changed files with 1327 additions and 8826 deletions

1
.gitignore vendored
View file

@ -21,6 +21,7 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
coverage
dist
.env
/tsconfig.tsbuildinfo

5
CLAUDE.md Normal file
View file

@ -0,0 +1,5 @@
# CLAUDE.md
## Known Issues
- Lint, format, and spellcheck scripts fail when `obsidian-dev-utils` is installed via `file:` protocol because transitive tool dependencies (cspell, dprint, eslint-plugin-no-unsanitized) are not hoisted into the consumer's `node_modules`. This will resolve when obsidian-dev-utils v2 is published to npm.

View file

@ -1,6 +1,7 @@
{
"version": "0.2",
"ignorePaths": [
"coverage",
"dist",
"node_modules",
"tsconfig.tsbuildinfo"
@ -11,8 +12,11 @@
"backlink",
"backlinks",
"dblclick",
"jsdom",
"lcov",
"mnaoumov",
"Naumov",
"noopAsync",
"tsbuildinfo"
],
"ignoreWords": [],

9604
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,8 @@
"lint:md:fix": "jiti scripts/lint-md-fix.ts",
"prepare": "jiti scripts/prepare.ts",
"spellcheck": "jiti scripts/spellcheck.ts",
"test": "jiti scripts/test.ts",
"test:coverage": "jiti scripts/test-coverage.ts",
"version": "jiti scripts/version.ts"
},
"devDependencies": {
@ -30,16 +32,21 @@
"@total-typescript/ts-reset": "latest",
"@tsconfig/strictest": "latest",
"@types/node": "25.0.3",
"@vitest/coverage-v8": "latest",
"better-typescript-lib": "latest",
"commitizen": "latest",
"cz-conventional-changelog": "latest",
"eslint": "latest",
"husky": "latest",
"jiti": "latest",
"jsdom": "latest",
"nano-staged": "latest",
"obsidian": "latest",
"obsidian-dev-utils": "latest",
"obsidian-typings": "obsidian-public-latest"
"obsidian-dev-utils": "file:F:/tmp/obsidian-dev-utils-v2",
"obsidian-test-mocks": "latest",
"obsidian-typings": "obsidian-public-latest",
"sass-embedded": "latest",
"vitest": "latest"
},
"overrides": {
"@antfu/utils": "9.2.0",

4
scripts/test-coverage.ts Normal file
View file

@ -0,0 +1,4 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { testCoverage } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() => testCoverage());

4
scripts/test.ts Normal file
View file

@ -0,0 +1,4 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() => test());

30
scripts/vitest-config.ts Normal file
View file

@ -0,0 +1,30 @@
import { defineConfig } from 'vitest/config';
export const config = defineConfig({
resolve: {
alias: {
obsidian: 'obsidian-test-mocks/obsidian'
}
},
test: {
coverage: {
exclude: [
'src/**/*.test.ts'
],
include: ['src/**/*.ts'],
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
reportsDirectory: './coverage'
},
environment: 'jsdom',
globals: false,
include: ['src/**/*.test.ts'],
onConsoleLog: (): false => false,
server: {
deps: {
inline: ['obsidian-typings']
}
},
setupFiles: ['obsidian-test-mocks/setup']
}
});

View file

@ -1,9 +0,0 @@
export class PluginSettings {
public pathDepth = 0;
public rootPaths: string[] = [];
public shouldDisplayParentPathOnSeparateLine = false;
public shouldHighlightFileName = true;
public shouldIncludeExtension = true;
public shouldReversePathParts = false;
public shouldShowEllipsisForSkippedPathParts = true;
}

View file

@ -1,11 +0,0 @@
import { PluginSettingsManagerBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-manager-base';
import type { PluginTypes } from './PluginTypes.ts';
import { PluginSettings } from './PluginSettings.ts';
export class PluginSettingsManager extends PluginSettingsManagerBase<PluginTypes> {
protected override createDefaultSettings(): PluginSettings {
return new PluginSettings();
}
}

View file

@ -1,13 +0,0 @@
import type { PluginTypesBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-types-base';
import type { Plugin } from './Plugin.ts';
import type { PluginSettings } from './PluginSettings.ts';
import type { PluginSettingsManager } from './PluginSettingsManager.ts';
import type { PluginSettingsTab } from './PluginSettingsTab.ts';
export interface PluginTypes extends PluginTypesBase {
plugin: Plugin;
pluginSettings: PluginSettings;
pluginSettingsManager: PluginSettingsManager;
pluginSettingsTab: PluginSettingsTab;
}

20
src/main.test.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* @file
*
* Tests for the main module default export.
*/
import {
describe,
expect,
it
} from 'vitest';
import { Plugin } from './plugin.ts';
describe('main', () => {
it('should export Plugin as default export', async () => {
const mainModule = await import('./main.ts') as { default: typeof Plugin };
expect(mainModule.default).toBe(Plugin);
});
});

View file

@ -1,4 +1,4 @@
import './styles/main.scss';
import { Plugin } from './Plugin.ts';
import { Plugin } from './plugin.ts';
export default Plugin;

View file

@ -0,0 +1,42 @@
/**
* @file
*
* Tests for PluginSettingsComponent.
*/
import {
describe,
expect,
it,
vi
} from 'vitest';
import { PluginSettings } from './plugin-settings.ts';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
describe('PluginSettingsComponent', () => {
it('should create default settings as PluginSettings instance', () => {
const component = new PluginSettingsComponent({
loadData: vi.fn().mockResolvedValue(null),
saveData: vi.fn().mockResolvedValue(undefined)
});
expect(component.defaultSettings).toBeInstanceOf(PluginSettings);
});
it('should have all 7 default setting properties', () => {
const component = new PluginSettingsComponent({
loadData: vi.fn().mockResolvedValue(null),
saveData: vi.fn().mockResolvedValue(undefined)
});
const defaults = component.defaultSettings;
expect(defaults).toHaveProperty('pathDepth');
expect(defaults).toHaveProperty('rootPaths');
expect(defaults).toHaveProperty('shouldDisplayParentPathOnSeparateLine');
expect(defaults).toHaveProperty('shouldHighlightFileName');
expect(defaults).toHaveProperty('shouldIncludeExtension');
expect(defaults).toHaveProperty('shouldReversePathParts');
expect(defaults).toHaveProperty('shouldShowEllipsisForSkippedPathParts');
});
});

View file

@ -0,0 +1,34 @@
/**
* @file
*
* Settings component that manages plugin settings persistence.
*/
import type { PluginSettingsComponentParams } from 'obsidian-dev-utils/obsidian/plugin/components/plugin-settings-component';
import { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/plugin/components/plugin-settings-component';
import { PluginSettings } from './plugin-settings.ts';
/**
* Manages persistence and lifecycle for {@link PluginSettings}.
*/
export class PluginSettingsComponent extends PluginSettingsComponentBase<PluginSettings> {
/**
* Creates a new settings component.
*
* @param params - Load/save data callbacks.
*/
public constructor(params: PluginSettingsComponentParams) {
super(params);
}
/**
* Creates the default settings instance.
*
* @returns A new {@link PluginSettings} with default values.
*/
protected override createDefaultSettings(): PluginSettings {
return new PluginSettings();
}
}

View file

@ -0,0 +1,70 @@
/**
* @file
*
* Tests for the PluginSettingsTab class.
*/
import type { Plugin as ObsidianPlugin } from 'obsidian';
import { App } from 'obsidian-test-mocks/obsidian';
import {
describe,
expect,
it,
vi
} from 'vitest';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
/**
* Creates a mock plugin object for testing.
*
* @returns A mock plugin.
*/
function createMockPlugin(): ObsidianPlugin {
const app = App.createConfigured__();
return {
app: app.asOriginalType__(),
manifest: {
author: 'test',
description: 'test',
id: 'backlink-full-path',
minAppVersion: '0.0.0',
name: 'Backlink Full Path',
version: '1.0.0'
}
} as unknown as ObsidianPlugin;
}
describe('PluginSettingsTab', () => {
it('should be constructable', () => {
const plugin = createMockPlugin();
const settingsComponent = new PluginSettingsComponent({
loadData: vi.fn().mockResolvedValue(null),
saveData: vi.fn().mockResolvedValue(undefined)
});
const tab = new PluginSettingsTab({
plugin,
settingsComponent
});
expect(tab).toBeInstanceOf(PluginSettingsTab);
});
it('should have a containerEl after construction', () => {
const plugin = createMockPlugin();
const settingsComponent = new PluginSettingsComponent({
loadData: vi.fn().mockResolvedValue(null),
saveData: vi.fn().mockResolvedValue(undefined)
});
const tab = new PluginSettingsTab({
plugin,
settingsComponent
});
expect(tab.containerEl).toBeDefined();
});
});

View file

@ -1,14 +1,36 @@
/**
* @file
*
* Settings tab UI for the Backlink Full Path plugin.
*/
import type { PluginSettingsTabBaseParams } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab';
import { Setting } from 'obsidian';
import { appendCodeBlock } from 'obsidian-dev-utils/html-element';
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab-base';
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab';
import { SettingEx } from 'obsidian-dev-utils/obsidian/setting-ex';
import type { PluginTypes } from './PluginTypes.ts';
import type { PluginSettings } from './plugin-settings.ts';
export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
/**
* Settings tab for the Backlink Full Path plugin.
*/
export class PluginSettingsTab extends PluginSettingsTabBase<PluginSettings> {
/**
* Creates a new settings tab.
*
* @param params - The settings tab params.
*/
public constructor(params: PluginSettingsTabBaseParams<PluginSettings>) {
super(params);
}
/**
* Renders the settings tab UI.
*/
public override display(): void {
super.display();
this.containerEl.empty();
new Setting(this.containerEl)
.setName('Include extension')

View file

@ -0,0 +1,50 @@
/**
* @file
*
* Tests for PluginSettings default values.
*/
import {
describe,
expect,
it
} from 'vitest';
import { PluginSettings } from './plugin-settings.ts';
describe('PluginSettings', () => {
it('should have correct default pathDepth', () => {
const settings = new PluginSettings();
expect(settings.pathDepth).toBe(0);
});
it('should have correct default rootPaths', () => {
const settings = new PluginSettings();
expect(settings.rootPaths).toEqual([]);
});
it('should have correct default shouldDisplayParentPathOnSeparateLine', () => {
const settings = new PluginSettings();
expect(settings.shouldDisplayParentPathOnSeparateLine).toBe(false);
});
it('should have correct default shouldHighlightFileName', () => {
const settings = new PluginSettings();
expect(settings.shouldHighlightFileName).toBe(true);
});
it('should have correct default shouldIncludeExtension', () => {
const settings = new PluginSettings();
expect(settings.shouldIncludeExtension).toBe(true);
});
it('should have correct default shouldReversePathParts', () => {
const settings = new PluginSettings();
expect(settings.shouldReversePathParts).toBe(false);
});
it('should have correct default shouldShowEllipsisForSkippedPathParts', () => {
const settings = new PluginSettings();
expect(settings.shouldShowEllipsisForSkippedPathParts).toBe(true);
});
});

45
src/plugin-settings.ts Normal file
View file

@ -0,0 +1,45 @@
/**
* @file
*
* Plugin settings data class with default values.
*/
/**
* Settings for the Backlink Full Path plugin.
*/
export class PluginSettings {
/**
* The depth of the path to include in backlinks (0 for unlimited).
*/
public pathDepth = 0;
/**
* The paths to be treated as root paths.
*/
public rootPaths: string[] = [];
/**
* Whether to display the parent path on a separate line.
*/
public shouldDisplayParentPathOnSeparateLine = false;
/**
* Whether to highlight the file name.
*/
public shouldHighlightFileName = true;
/**
* Whether to include the file extension in backlinks.
*/
public shouldIncludeExtension = true;
/**
* Whether to reverse the path parts.
*/
public shouldReversePathParts = false;
/**
* Whether to show ellipsis for skipped path parts.
*/
public shouldShowEllipsisForSkippedPathParts = true;
}

48
src/plugin.test.ts Normal file
View file

@ -0,0 +1,48 @@
/**
* @file
*
* Tests for the Plugin class.
*/
import type { PluginManifest } from 'obsidian';
import { App } from 'obsidian-test-mocks/obsidian';
import {
describe,
expect,
it
} from 'vitest';
import { Plugin } from './plugin.ts';
/**
* Creates a minimal PluginManifest for testing.
*
* @returns A test PluginManifest.
*/
function createTestManifest(): PluginManifest {
return {
author: 'test',
description: 'test',
id: 'backlink-full-path',
minAppVersion: '0.0.0',
name: 'Backlink Full Path',
version: '1.0.0'
};
}
describe('Plugin', () => {
it('should be constructable', () => {
const app = App.createConfigured__();
const manifest = createTestManifest();
const plugin = new Plugin(app.asOriginalType__(), manifest);
expect(plugin).toBeInstanceOf(Plugin);
});
it('should have the correct manifest id', () => {
const app = App.createConfigured__();
const manifest = createTestManifest();
const plugin = new Plugin(app.asOriginalType__(), manifest);
expect(plugin.manifest.id).toBe('backlink-full-path');
});
});

View file

@ -1,4 +1,15 @@
import type { TFile } from 'obsidian';
/**
* @file
*
* Main plugin class for the Backlink Full Path plugin.
*/
import type {
App,
PluginManifest,
TFile
} from 'obsidian';
import type { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab';
import type {
BacklinkPlugin,
BacklinkView,
@ -14,32 +25,71 @@ import {
import { invokeAsyncSafely } from 'obsidian-dev-utils/async';
import { getPrototypeOf } from 'obsidian-dev-utils/object-utils';
import { registerPatch } from 'obsidian-dev-utils/obsidian/monkey-around';
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-base';
import { PluginSettingsTabComponent } from 'obsidian-dev-utils/obsidian/plugin/components/plugin-settings-tab-component';
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin';
import {
InternalPluginName,
ViewType
} from 'obsidian-typings/implementations';
import type { PluginTypes } from './PluginTypes.ts';
import type { PluginSettings } from './plugin-settings.ts';
import { PluginSettingsManager } from './PluginSettingsManager.ts';
import { PluginSettingsTab } from './PluginSettingsTab.ts';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
/**
* Type alias for the `addResult` method on `ResultDom`.
*/
type AddResultFn = ResultDom['addResult'];
export class Plugin extends PluginBase<PluginTypes> {
public override async onSaveSettings(): Promise<void> {
await this.refreshBacklinkPanels();
/**
* The Backlink Full Path plugin. Replaces backlink titles with full paths.
*/
export class Plugin extends PluginBase {
private readonly pluginSettingsComponent: PluginSettingsComponent;
/**
* Creates a new Backlink Full Path plugin.
*
* @param app - The Obsidian app instance.
* @param manifest - The plugin manifest.
*/
public constructor(app: App, manifest: PluginManifest) {
super(app, manifest);
this.pluginSettingsComponent = this.registerComponent({
component: new PluginSettingsComponent({
loadData: this.loadData.bind(this),
saveData: this.saveData.bind(this)
}),
shouldPreload: true
});
this.registerComponent({
component: new PluginSettingsTabComponent(
this,
new PluginSettingsTab({
plugin: this,
settingsComponent: this.pluginSettingsComponent
}) as PluginSettingsTabBase<object>
)
});
}
protected override createSettingsManager(): PluginSettingsManager {
return new PluginSettingsManager(this);
}
protected override createSettingsTab(): null | PluginSettingsTab {
return new PluginSettingsTab(this);
/**
* Called after pre-loaded components are initialized.
*/
protected override async onloadImpl(): Promise<void> {
await super.onloadImpl();
this.pluginSettingsComponent.on('saveSettings', () => {
invokeAsyncSafely(() => this.refreshBacklinkPanels());
});
}
/**
* Called when the workspace layout is ready.
*/
protected override async onLayoutReady(): Promise<void> {
const backlinksCorePlugin = this.app.internalPlugins.getPluginById(InternalPluginName.Backlink);
if (!backlinksCorePlugin) {
@ -68,6 +118,10 @@ export class Plugin extends PluginBase<PluginTypes> {
});
}
private get pluginSettings(): PluginSettings {
return this.pluginSettingsComponent.settings as PluginSettings;
}
private addResult(next: AddResultFn, resultDom: ResultDom, file: TFile, result: ResultDomResult, content: string, shouldShowTitle?: boolean): ResultDomItem {
const resultDomItem = next.call(resultDom, file, result, content, shouldShowTitle);
const fileNameCaptionEl = resultDomItem.el.querySelector('.tree-item-inner');
@ -79,40 +133,40 @@ export class Plugin extends PluginBase<PluginTypes> {
}
private generateBacklinkTitle(file: TFile): HTMLDivElement {
const fileNamePart = this.settings.shouldIncludeExtension ? file.name : file.basename;
const fileNamePart = this.pluginSettings.shouldIncludeExtension ? file.name : file.basename;
let parentPathParts = file.path.split('/').slice(0, -1);
for (let length = parentPathParts.length; length >= 1; length--) {
const rootPath = parentPathParts.slice(0, length).join('/');
if (this.settings.rootPaths.includes(rootPath)) {
if (this.pluginSettings.rootPaths.includes(rootPath)) {
parentPathParts = parentPathParts.slice(length);
break;
}
}
if (this.settings.pathDepth > 0) {
const partsToSkipCount = Math.max(0, parentPathParts.length - this.settings.pathDepth + 1);
if (this.pluginSettings.pathDepth > 0) {
const partsToSkipCount = Math.max(0, parentPathParts.length - this.pluginSettings.pathDepth + 1);
if (partsToSkipCount > 0) {
parentPathParts.splice(0, partsToSkipCount);
if (this.settings.shouldShowEllipsisForSkippedPathParts) {
if (this.pluginSettings.shouldShowEllipsisForSkippedPathParts) {
parentPathParts.unshift('...');
}
}
}
if (this.settings.shouldReversePathParts) {
if (this.pluginSettings.shouldReversePathParts) {
parentPathParts.reverse();
}
const pathSeparator = this.settings.shouldReversePathParts ? ' ← ' : '/';
const pathSeparator = this.pluginSettings.shouldReversePathParts ? ' \u2190 ' : '/';
const parentStr = parentPathParts.join(pathSeparator);
const container = createDiv({
cls: ['backlink-full-path', 'backlink-control']
});
container.dataset['shouldHighlightFileName'] = this.settings.shouldHighlightFileName.toString();
container.dataset['shouldDisplayParentPathOnSeparateLine'] = this.settings.shouldDisplayParentPathOnSeparateLine.toString();
container.dataset['shouldHighlightFileName'] = this.pluginSettings.shouldHighlightFileName.toString();
container.dataset['shouldDisplayParentPathOnSeparateLine'] = this.pluginSettings.shouldDisplayParentPathOnSeparateLine.toString();
container.createSpan({
cls: 'full-path',
text: file.path
@ -128,14 +182,14 @@ export class Plugin extends PluginBase<PluginTypes> {
if (parentStr) {
let text = parentStr;
if (!this.settings.shouldDisplayParentPathOnSeparateLine) {
text = this.settings.shouldReversePathParts ? pathSeparator + text : text + pathSeparator;
if (!this.pluginSettings.shouldDisplayParentPathOnSeparateLine) {
text = this.pluginSettings.shouldReversePathParts ? pathSeparator + text : text + pathSeparator;
}
shadowRoot.createSpan({
attr: {
part: 'parent-path'
},
prepend: !this.settings.shouldReversePathParts && !this.settings.shouldDisplayParentPathOnSeparateLine,
prepend: !this.pluginSettings.shouldReversePathParts && !this.pluginSettings.shouldDisplayParentPathOnSeparateLine,
text
});
}

View file

@ -3,7 +3,6 @@
"compilerOptions": {
"allowArbitraryExtensions": true,
"allowImportingTsExtensions": true,
"allowJs": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"importHelpers": true,
@ -22,8 +21,7 @@
"types": [
"@total-typescript/ts-reset",
"node",
"obsidian-typings",
"svelte"
"obsidian-typings"
],
"verbatimModuleSyntax": true
},
@ -31,7 +29,8 @@
"./.markdownlint-cli2.mts",
"./commitlint.config.ts",
"./eslint.config.mts",
"./scripts/**/*.ts",
"./src/**/*.ts",
"./scripts/**/*.ts"
"./vitest.config.ts"
]
}

3
vitest.config.ts Normal file
View file

@ -0,0 +1,3 @@
import { config } from './scripts/vitest-config.ts';
export default config;