chore: update template

This commit is contained in:
Michael Naumov 2026-05-26 12:37:41 -06:00
parent cc483ff1a1
commit fe25a77d33
12 changed files with 1317 additions and 631 deletions

View file

@ -17,7 +17,8 @@
"mnaoumov",
"Naumov",
"noopAsync",
"tsbuildinfo"
"tsbuildinfo",
"unsanitized"
],
"ignoreWords": [],
"import": [],

861
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -27,9 +27,15 @@
"spellcheck": "jiti scripts/spellcheck.ts",
"test": "jiti scripts/test.ts",
"test:coverage": "jiti scripts/test-coverage.ts",
"test:integration": "jiti scripts/test-integration.ts",
"test:integration:android": "jiti scripts/test-integration-android.ts",
"test:integration:desktop": "jiti scripts/test-integration-desktop.ts",
"test:integration:no-app": "jiti scripts/test-integration-no-app.ts",
"test:watch": "jiti scripts/test-watch.ts",
"version": "jiti scripts/version.ts"
},
"overrides": {
"@vitest/coverage-v8": "$@vitest/coverage-v8",
"eslint": "^10.4.0",
"type-fest": "^5.6.0",
"typescript": "^6.0.3"
@ -38,11 +44,11 @@
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@commitlint/types": "^21.0.1",
"@obsidian-typings/obsidian-public-latest": "^6.4.0",
"@obsidian-typings/obsidian-public-latest": "^6.11.0",
"@total-typescript/ts-reset": "^0.6.1",
"@tsconfig/strictest": "^2.0.8",
"@types/node": "^25.8.0",
"@vitest/coverage-v8": "^4.1.6",
"@types/node": "^25.9.1",
"@vitest/coverage-v8": "^4.1.7",
"better-typescript-lib": "^2.12.0",
"czg": "^1.13.1",
"eslint": "^10.4.0",
@ -51,9 +57,10 @@
"jsdom": "^29.1.1",
"nano-staged": "^1.0.2",
"obsidian": "^1.12.3",
"obsidian-dev-utils": "^66.0.1",
"obsidian-test-mocks": "^2.0.3",
"sass-embedded": "^1.99.0",
"vitest": "^4.1.6"
"obsidian-dev-utils": "^69.1.1",
"obsidian-integration-testing": "^4.1.5",
"obsidian-test-mocks": "^3.0.0",
"sass-embedded": "^1.100.0",
"vitest": "^4.1.7"
}
}

View file

@ -25,6 +25,9 @@ export const config = defineConfig({
inline: ['@obsidian-typings', 'obsidian-dev-utils']
}
},
setupFiles: ['obsidian-test-mocks/setup']
setupFiles: [
'obsidian-test-mocks/vitest-setup',
'obsidian-test-mocks/obsidian-typings/vitest-setup'
]
}
});

View file

@ -10,11 +10,12 @@ import {
it
} from 'vitest';
// eslint-disable-next-line import-x/no-rename-default -- Renamed to avoid collision with the named `Plugin` import.
import mainExport from './main.ts';
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);
it('should export Plugin as default export', () => {
expect(mainExport).toBe(Plugin);
});
});

View file

@ -1,42 +0,0 @@
/**
* @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

@ -4,20 +4,23 @@
* Settings component that manages plugin settings persistence.
*/
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component';
import { PluginSettings } from './plugin-settings.ts';
/**
* Manages persistence and lifecycle for {@link PluginSettings}.
*/
interface PluginSettingsComponentConstructorParams {
readonly dataHandler: DataHandler;
readonly pluginEventSource: PluginEventSource;
}
export class PluginSettingsComponent extends PluginSettingsComponentBase<PluginSettings> {
/**
* Creates the default settings instance.
*
* @returns A new {@link PluginSettings} with default values.
*/
protected override createDefaultSettings(): PluginSettings {
return new PluginSettings();
public constructor(params: PluginSettingsComponentConstructorParams) {
super({
...params,
pluginSettingsClass: PluginSettings
});
}
}

View file

@ -4,67 +4,159 @@
* Tests for the PluginSettingsTab class.
*/
import type { Plugin as ObsidianPlugin } from 'obsidian';
import type { PluginManifest } from 'obsidian';
import { App } from 'obsidian-test-mocks/obsidian';
import {
App,
ToggleComponent
} from 'obsidian-test-mocks/obsidian';
import {
beforeAll,
describe,
expect,
it,
vi
it
} from 'vitest';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
import { Plugin } from './plugin.ts';
interface DisplayedTabResult {
names: string[];
tab: PluginSettingsTab;
}
/**
* Creates a mock plugin object for testing.
* Creates a PluginSettingsTab attached to a freshly created Plugin and calls display().
*
* @returns A mock plugin.
* @returns An object with the tab and the extracted setting names.
*/
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;
function createDisplayedTab(): DisplayedTabResult {
const plugin = createPlugin();
const pluginSettingsComponent = Reflect.get(plugin, 'pluginSettingsComponent') as InstanceType<
typeof import('./plugin-settings-component.ts').PluginSettingsComponent
>;
const tab = new PluginSettingsTab({
plugin,
pluginSettingsComponent
});
tab.display();
const names = getSettingNames(tab.containerEl);
return { names, tab };
}
/**
* Creates a Plugin instance with mocked dependencies.
*
* @returns The created Plugin.
*/
function createPlugin(): Plugin {
const app = App.createConfigured__();
const manifest: PluginManifest = {
author: 'test',
description: 'test',
id: 'backlink-full-path',
minAppVersion: '0.0.0',
name: 'Backlink Full Path',
version: '1.0.0'
};
return new Plugin(app.asOriginalType__(), manifest);
}
/**
* Extracts setting names from the tab's containerEl after calling display().
*
* Each Setting mock creates a settingEl > [controlEl, infoEl > [nameEl, descEl]].
* The nameEl is the second-level div that contains the setting name text.
*
* @param containerEl - The container element.
* @returns The array of setting name strings.
*/
function getSettingNames(containerEl: HTMLElement): string[] {
const names: string[] = [];
for (const settingEl of containerEl.children) {
// SettingEl contains [controlEl, infoEl].
// InfoEl contains [nameEl, descEl].
const infoEl = settingEl.children[1];
if (infoEl) {
const nameEl = infoEl.children[0];
if (nameEl?.textContent) {
names.push(nameEl.textContent);
}
}
}
return names;
}
/**
* Patches missing mock properties on obsidian-test-mocks components
* that obsidian-dev-utils's `bind()` method probes via strict proxy.
*/
function patchMissingMockProperties(): void {
// Obsidian-dev-utils checks for setPlaceholderValue to detect text-based components.
if (!('setPlaceholderValue' in ToggleComponent.prototype)) {
Object.defineProperty(ToggleComponent.prototype, 'setPlaceholderValue', { value: undefined });
}
}
beforeAll(() => {
patchMissingMockProperties();
});
describe('PluginSettingsTab', () => {
it('should be constructable', () => {
const plugin = createMockPlugin();
const settingsComponent = new PluginSettingsComponent({
loadData: vi.fn().mockResolvedValue(null),
saveData: vi.fn().mockResolvedValue(undefined)
});
const plugin = createPlugin();
const pluginSettingsComponent = Reflect.get(plugin, 'pluginSettingsComponent') as InstanceType<
typeof import('./plugin-settings-component.ts').PluginSettingsComponent
>;
const tab = new PluginSettingsTab({
plugin,
settingsComponent
pluginSettingsComponent
});
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)
describe('display', () => {
it('should create all 7 settings', () => {
const { names } = createDisplayedTab();
const EXPECTED_SETTING_COUNT = 7;
expect(names.length).toBe(EXPECTED_SETTING_COUNT);
});
const tab = new PluginSettingsTab({
plugin,
settingsComponent
it('should create Include extension setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Include extension');
});
expect(tab.containerEl).toBeDefined();
it('should create Path depth setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Path depth');
});
it('should create Show ellipsis for skipped path parts setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Show ellipsis for skipped path parts');
});
it('should create Highlight file name setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Highlight file name');
});
it('should create Reverse path parts setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Reverse path parts');
});
it('should create Display parent path on separate line setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Display parent path on separate line');
});
it('should create Root paths setting', () => {
const { names } = createDisplayedTab();
expect(names).toContain('Root paths');
});
});
});

View file

@ -1,11 +1,3 @@
/**
* @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';
@ -13,22 +5,7 @@ import { SettingEx } from 'obsidian-dev-utils/obsidian/setting-ex';
import type { PluginSettings } from './plugin-settings.ts';
/**
* 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();

View file

@ -4,17 +4,91 @@
* Tests for the Plugin class.
*/
import type { PluginManifest } from 'obsidian';
import type {
BacklinkView,
ResultDom,
ResultDomItem,
ResultDomResult
} from '@obsidian-typings/obsidian-public-latest';
import type {
PluginManifest,
TFile
} from 'obsidian';
import type { PartialDeep } from 'type-fest';
import { App } from 'obsidian-test-mocks/obsidian';
import {
bypassStrictProxy,
strictProxy
} from 'obsidian-dev-utils/strict-proxy';
import {
App,
MarkdownView,
WorkspaceLeaf
} from 'obsidian-test-mocks/obsidian';
import {
beforeEach,
describe,
expect,
it
it,
vi
} from 'vitest';
import type { PluginSettings } from './plugin-settings.ts';
import { Plugin } from './plugin.ts';
interface MockCorePlugin {
enabled: boolean;
instance: object;
}
interface SettingsComponentProxy {
on(...args: never[]): void;
settings: PluginSettings;
}
interface TestPluginResult {
app: ReturnType<typeof App.createConfigured__>;
plugin: Plugin;
}
/**
* Creates a mock TFile for testing.
*
* @param path - The file path.
* @returns A mock TFile.
*/
function createMockFile(path: string): TFile {
const parts = path.split('/');
const name = parts.at(-1) ?? '';
const dotIndex = name.lastIndexOf('.');
const basename = dotIndex >= 0 ? name.slice(0, dotIndex) : name;
const extension = dotIndex >= 0 ? name.slice(dotIndex + 1) : '';
return mockProxy<TFile>({
basename,
extension,
name,
path
});
}
/**
* Creates a mock ResultDomItem with an element that has .tree-item-inner.
*
* @param hasTreeItemInner - Whether the result element should have a .tree-item-inner child.
* @returns The mock ResultDomItem.
*/
function createMockResultDomItem(hasTreeItemInner: boolean): ResultDomItem {
const el = activeDocument.createElement('div');
if (hasTreeItemInner) {
const inner = activeDocument.createElement('div');
inner.classList.add('tree-item-inner');
inner.textContent = 'Original';
el.appendChild(inner);
}
return mockProxy<ResultDomItem>({ el });
}
/**
* Creates a minimal PluginManifest for testing.
*
@ -31,18 +105,659 @@ function createTestManifest(): PluginManifest {
};
}
/**
* Creates a test Plugin instance with a configured App.
*
* @returns The plugin and app.
*/
function createTestPlugin(): TestPluginResult {
const app = App.createConfigured__();
const manifest = createTestManifest();
const plugin = new Plugin(app.asOriginalType__(), manifest);
return { app, plugin };
}
/**
* Gets the plugin's private pluginSettingsComponent via Reflect.
*
* @param plugin - The plugin instance.
* @returns The pluginSettingsComponent.
*/
function getSettingsComponent(plugin: Plugin): SettingsComponentProxy {
return Reflect.get(plugin, 'pluginSettingsComponent') as SettingsComponentProxy;
}
/**
* Sets `_loaded` to true on the plugin (bypassing strict proxy).
*
* @param plugin - The plugin to mark as loaded.
*/
function markPluginLoaded(plugin: Plugin): void {
Reflect.set(bypassStrictProxy(plugin), '_loaded', true);
}
/**
* Sets up internalPlugins mock on the plugin's app.
*
* @param plugin - The plugin.
* @param returnValue - The value to return from getPluginById.
* @returns The mocked getPluginById function.
*/
function mockInternalPlugins(plugin: Plugin, returnValue: unknown): ReturnType<typeof vi.fn> {
const getPluginById = vi.fn().mockReturnValue(returnValue);
Reflect.set(bypassStrictProxy(plugin.app), 'internalPlugins', strictProxy({ getPluginById }));
return getPluginById;
}
/**
* Creates a strict proxy, working around `PartialDeep<T>` incompatibility
* with complex DOM types by casting the partial before passing to strictProxy.
*
* @param partial - A partial object with the mocked members.
* @returns A strict proxy typed as T.
*/
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is used in the body for strictProxy<T>.
function mockProxy<T>(partial: object): T {
return strictProxy<T>(partial as PartialDeep<T>);
}
/**
* Calls a private/protected method on an object via Reflect and binds it.
*
* @param obj - The object.
* @param name - The method name.
* @returns The bound method.
*/
function privateMethod(obj: object, name: string): (...args: never[]) => unknown {
const fn = Reflect.get(obj, name) as (...args: never[]) => unknown;
return fn.bind(obj);
}
describe('Plugin', () => {
it('should be constructable', () => {
const app = App.createConfigured__();
const manifest = createTestManifest();
const plugin = new Plugin(app.asOriginalType__(), manifest);
const { plugin } = createTestPlugin();
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);
const { plugin } = createTestPlugin();
expect(plugin.manifest.id).toBe('backlink-full-path');
});
describe('onload', () => {
it('should register saveSettings event listener', () => {
const { plugin } = createTestPlugin();
const settingsComponent = getSettingsComponent(plugin);
const onSpy = vi.spyOn(settingsComponent, 'on');
plugin.onload();
expect(onSpy).toHaveBeenCalledWith('saveSettings', expect.any(Function));
});
it('should call refreshBacklinkPanels when saveSettings is triggered', async () => {
const { plugin } = createTestPlugin();
const refreshBacklinkPanels = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'refreshBacklinkPanels', refreshBacklinkPanels);
const settingsComponent = getSettingsComponent(plugin);
let savedCallback: (() => Promise<void>) | undefined;
vi.spyOn(settingsComponent, 'on').mockImplementation(
((_event: never, callback: never) => {
savedCallback = callback;
}) as (...args: never[]) => void
);
plugin.onload();
expect(savedCallback).toBeDefined();
if (savedCallback) {
await savedCallback();
}
expect(refreshBacklinkPanels).toHaveBeenCalled();
});
});
describe('onLayoutReady', () => {
it('should return early when backlinks core plugin is not found', async () => {
const { plugin } = createTestPlugin();
const getPluginById = mockInternalPlugins(plugin, undefined);
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
expect(getPluginById).toHaveBeenCalledWith('backlink');
});
it('should patch but not call patchBacklinksPane when backlinks plugin is disabled', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const instanceProto = {};
const backlinksCorePlugin = mockProxy<MockCorePlugin>({
enabled: false,
instance: Object.create(instanceProto)
});
mockInternalPlugins(plugin, backlinksCorePlugin);
const patchBacklinksPane = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'patchBacklinksPane', patchBacklinksPane);
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
expect(patchBacklinksPane).not.toHaveBeenCalled();
});
it('should call patchBacklinksPane and refreshBacklinkPanels when backlinks plugin is enabled', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const instanceProto = {};
const backlinksCorePlugin = mockProxy<MockCorePlugin>({
enabled: true,
instance: Object.create(instanceProto)
});
mockInternalPlugins(plugin, backlinksCorePlugin);
const patchBacklinksPane = vi.fn().mockResolvedValue(undefined);
const refreshBacklinkPanels = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'patchBacklinksPane', patchBacklinksPane);
Reflect.set(plugin, 'refreshBacklinkPanels', refreshBacklinkPanels);
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
expect(patchBacklinksPane).toHaveBeenCalled();
expect(refreshBacklinkPanels).toHaveBeenCalled();
});
it('should invoke onUserEnable patch that calls onBacklinksCorePluginEnable', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const instanceProto = { onUserEnable: vi.fn() };
const backlinksCorePlugin = mockProxy<MockCorePlugin>({
enabled: false,
instance: Object.create(instanceProto) as object
});
mockInternalPlugins(plugin, backlinksCorePlugin);
const onBacklinksCorePluginEnable = vi.fn();
Reflect.set(plugin, 'onBacklinksCorePluginEnable', onBacklinksCorePluginEnable);
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
const patchedOnUserEnable = instanceProto.onUserEnable as () => void;
patchedOnUserEnable.call(backlinksCorePlugin.instance);
expect(onBacklinksCorePluginEnable).toHaveBeenCalled();
});
it('should register unload callback that calls refreshBacklinkPanels', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const instanceProto = {};
const backlinksCorePlugin = mockProxy<MockCorePlugin>({
enabled: false,
instance: Object.create(instanceProto)
});
mockInternalPlugins(plugin, backlinksCorePlugin);
const refreshBacklinkPanels = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'refreshBacklinkPanels', refreshBacklinkPanels);
const registerCallbacks: (() => void)[] = [];
vi.spyOn(plugin, 'register').mockImplementation((cb: () => void) => {
registerCallbacks.push(cb);
});
const onLayoutReady = privateMethod(plugin, 'onLayoutReady');
await onLayoutReady();
expect(registerCallbacks.length).toBeGreaterThan(0);
const callback = registerCallbacks[0];
if (callback) {
callback();
}
expect(refreshBacklinkPanels).toHaveBeenCalled();
});
});
describe('addResult', () => {
let plugin: Plugin;
let addResult: (...args: never[]) => unknown;
beforeEach(() => {
const result = createTestPlugin();
plugin = result.plugin;
addResult = privateMethod(plugin, 'addResult');
});
it('should replace tree-item-inner content with generated backlink title', () => {
const file = createMockFile('folder/subfolder/note.md');
const resultDomItem = createMockResultDomItem(true);
const next = vi.fn().mockReturnValue(resultDomItem);
const resultDom = mockProxy<ResultDom>({});
const result = mockProxy<ResultDomResult>({});
const returnedItem = (addResult as (...args: unknown[]) => unknown)(next, resultDom, file, result, 'content', true) as ResultDomItem;
expect(next).toHaveBeenCalledWith(file, result, 'content', true);
expect(returnedItem).toBe(resultDomItem);
const inner = resultDomItem.el.querySelector('.tree-item-inner');
expect(inner?.textContent).not.toBe('Original');
});
it('should not modify result when tree-item-inner is absent', () => {
const file = createMockFile('note.md');
const resultDomItem = createMockResultDomItem(false);
const next = vi.fn().mockReturnValue(resultDomItem);
const resultDom = mockProxy<ResultDom>({});
const result = mockProxy<ResultDomResult>({});
const returnedItem = (addResult as (...args: unknown[]) => unknown)(next, resultDom, file, result, 'content') as ResultDomItem;
expect(returnedItem).toBe(resultDomItem);
});
});
describe('generateBacklinkTitle', () => {
let plugin: Plugin;
let settings: PluginSettings;
beforeEach(() => {
const result = createTestPlugin();
plugin = result.plugin;
settings = getSettingsComponent(plugin).settings;
});
/**
* Calls the private generateBacklinkTitle method.
*
* @param file - The TFile to generate a title for.
* @returns The generated HTMLDivElement.
*/
function callGenerateBacklinkTitle(file: TFile): HTMLDivElement {
const fn = privateMethod(plugin, 'generateBacklinkTitle');
return (fn as (...args: unknown[]) => unknown)(file) as HTMLDivElement;
}
it('should include extension when shouldIncludeExtension is true', () => {
settings.shouldIncludeExtension = true;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const fileNameSpan = container.shadowRoot?.querySelector('[part="file-name"]');
expect(fileNameSpan?.textContent).toBe('note.md');
});
it('should exclude extension when shouldIncludeExtension is false', () => {
settings.shouldIncludeExtension = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const fileNameSpan = container.shadowRoot?.querySelector('[part="file-name"]');
expect(fileNameSpan?.textContent).toBe('note');
});
it('should show file at root with no parent path', () => {
const file = createMockFile('note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan).toBeNull();
});
it('should show parent path with trailing separator', () => {
settings.shouldReversePathParts = false;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('folder/');
});
it('should strip root path prefix', () => {
settings.rootPaths = ['folder'];
const file = createMockFile('folder/subfolder/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('subfolder/');
});
it('should strip nested root path prefix', () => {
settings.rootPaths = ['folder/subfolder'];
const file = createMockFile('folder/subfolder/deep/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('deep/');
});
it('should limit depth with pathDepth setting', () => {
settings.pathDepth = 2;
settings.shouldShowEllipsisForSkippedPathParts = false;
const file = createMockFile('a/b/c/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('c/');
});
it('should add ellipsis for skipped parts when enabled', () => {
settings.pathDepth = 2;
settings.shouldShowEllipsisForSkippedPathParts = true;
const file = createMockFile('a/b/c/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('.../c/');
});
it('should not add ellipsis when pathDepth does not truncate', () => {
settings.pathDepth = 5;
settings.shouldShowEllipsisForSkippedPathParts = true;
const file = createMockFile('a/b/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('a/b/');
});
it('should reverse path parts when shouldReversePathParts is true', () => {
settings.shouldReversePathParts = true;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('a/b/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe(' \u2190 b \u2190 a');
});
it('should display parent path on separate line without separator', () => {
settings.shouldDisplayParentPathOnSeparateLine = true;
settings.shouldReversePathParts = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const parentPathSpan = container.shadowRoot?.querySelector('[part="parent-path"]');
expect(parentPathSpan?.textContent).toBe('folder');
});
it('should set dataset attributes for highlighting', () => {
settings.shouldHighlightFileName = true;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('note.md');
const container = callGenerateBacklinkTitle(file);
expect(container.dataset['shouldHighlightFileName']).toBe('true');
expect(container.dataset['shouldDisplayParentPathOnSeparateLine']).toBe('false');
});
it('should include full-path span with file path', () => {
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const fullPathSpan = container.querySelector('.full-path');
expect(fullPathSpan?.textContent).toBe('folder/note.md');
});
it('should prepend parent path when not reversed and not on separate line', () => {
settings.shouldReversePathParts = false;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const firstChild = container.shadowRoot?.firstElementChild;
expect(firstChild?.getAttribute('part')).toBe('parent-path');
});
it('should append parent path when reversed and not on separate line', () => {
settings.shouldReversePathParts = true;
settings.shouldDisplayParentPathOnSeparateLine = false;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const lastChild = container.shadowRoot?.lastElementChild;
expect(lastChild?.getAttribute('part')).toBe('parent-path');
});
it('should not prepend when displaying on separate line', () => {
settings.shouldReversePathParts = false;
settings.shouldDisplayParentPathOnSeparateLine = true;
const file = createMockFile('folder/note.md');
const container = callGenerateBacklinkTitle(file);
const lastChild = container.shadowRoot?.lastElementChild;
expect(lastChild?.getAttribute('part')).toBe('parent-path');
});
});
describe('getBacklinkView', () => {
it('should return null when no backlink leaf exists', async () => {
const { plugin } = createTestPlugin();
const getBacklinkView = privateMethod(plugin, 'getBacklinkView');
const view = await getBacklinkView();
expect(view).toBeNull();
});
it('should return the view when a backlink leaf exists', async () => {
const { plugin } = createTestPlugin();
const mockView = mockProxy<BacklinkView>({});
const mockLeaf = mockProxy<WorkspaceLeaf>({
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
view: mockView
});
Reflect.set(plugin.app.workspace, 'getLeavesOfType', vi.fn().mockReturnValue([mockLeaf]));
const getBacklinkView = privateMethod(plugin, 'getBacklinkView');
const view = await getBacklinkView();
expect(mockLeaf.loadIfDeferred).toHaveBeenCalled();
expect(view).toBe(mockView);
});
});
describe('refreshBacklinkPanels', () => {
it('should call reloadBacklinksView', async () => {
const { plugin } = createTestPlugin();
const reloadBacklinksView = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'reloadBacklinksView', reloadBacklinksView);
const refreshBacklinkPanels = privateMethod(plugin, 'refreshBacklinkPanels');
await refreshBacklinkPanels();
expect(reloadBacklinksView).toHaveBeenCalled();
});
});
describe('reloadBacklinksView', () => {
it('should do nothing when no backlink view exists', async () => {
const { plugin } = createTestPlugin();
const getBacklinkView = vi.fn().mockResolvedValue(null);
Reflect.set(plugin, 'getBacklinkView', getBacklinkView);
const reloadBacklinksView = privateMethod(plugin, 'reloadBacklinksView');
await reloadBacklinksView();
expect(getBacklinkView).toHaveBeenCalled();
});
it('should not recompute when backlink view has no file', async () => {
const { plugin } = createTestPlugin();
const recomputeBacklink = vi.fn();
const backlinkView = mockProxy<BacklinkView>({
backlink: mockProxy({ recomputeBacklink }),
file: null
});
Reflect.set(plugin, 'getBacklinkView', vi.fn().mockResolvedValue(backlinkView));
const reloadBacklinksView = privateMethod(plugin, 'reloadBacklinksView');
await reloadBacklinksView();
expect(recomputeBacklink).not.toHaveBeenCalled();
});
it('should recompute when backlink view has a file', async () => {
const { plugin } = createTestPlugin();
const mockFile = createMockFile('note.md');
const recomputeBacklink = vi.fn();
const backlinkView = mockProxy<BacklinkView>({
backlink: mockProxy({ recomputeBacklink }),
file: mockFile
});
Reflect.set(plugin, 'getBacklinkView', vi.fn().mockResolvedValue(backlinkView));
const reloadBacklinksView = privateMethod(plugin, 'reloadBacklinksView');
await reloadBacklinksView();
expect(recomputeBacklink).toHaveBeenCalledWith(mockFile);
});
});
describe('onBacklinksCorePluginEnable', () => {
it('should invoke patchBacklinksPane asynchronously', () => {
const { plugin } = createTestPlugin();
const patchBacklinksPane = vi.fn().mockResolvedValue(undefined);
Reflect.set(plugin, 'patchBacklinksPane', patchBacklinksPane);
const onBacklinksCorePluginEnable = privateMethod(plugin, 'onBacklinksCorePluginEnable');
onBacklinksCorePluginEnable();
expect(patchBacklinksPane).toHaveBeenCalled();
});
});
describe('patchBacklinksPane', () => {
it('should return early when no backlink view exists', async () => {
const { plugin } = createTestPlugin();
const getBacklinkView = vi.fn().mockResolvedValue(null);
Reflect.set(plugin, 'getBacklinkView', getBacklinkView);
const patchBacklinksPane = privateMethod(plugin, 'patchBacklinksPane');
await patchBacklinksPane();
expect(getBacklinkView).toHaveBeenCalled();
});
it('should patch addResult on backlinkDom when view exists', async () => {
const { plugin } = createTestPlugin();
markPluginLoaded(plugin);
const originalAddResult = vi.fn().mockImplementation(() => createMockResultDomItem(true));
const backlinkDomProto = { addResult: originalAddResult };
const backlinkView = mockProxy<BacklinkView>({
backlink: mockProxy({
backlinkDom: Object.create(backlinkDomProto) as object
})
});
Reflect.set(plugin, 'getBacklinkView', vi.fn().mockResolvedValue(backlinkView));
const patchBacklinksPane = privateMethod(plugin, 'patchBacklinksPane');
await patchBacklinksPane();
// The patchedAddResult is now on the prototype; call it to exercise the callback
const file = createMockFile('folder/note.md');
const result = mockProxy<ResultDomResult>({});
const mockResultDom = Object.create(backlinkDomProto) as object;
const patchedAddResult = backlinkDomProto.addResult as (
file: TFile,
result: ResultDomResult,
content: string,
shouldShowTitle?: boolean
) => ResultDomItem;
const item = patchedAddResult.call(mockResultDom, file, result, 'content', true);
expect(item).toBeDefined();
});
});
describe('refreshBacklinkPanels with markdown leaves', () => {
it('should skip leaves that are not MarkdownView instances', async () => {
const { plugin } = createTestPlugin();
Reflect.set(plugin, 'reloadBacklinksView', vi.fn().mockResolvedValue(undefined));
const nonMarkdownLeaf = mockProxy<WorkspaceLeaf>({
view: mockProxy({})
});
Reflect.set(
plugin.app.workspace,
'getLeavesOfType',
vi.fn().mockImplementation((type: string) => {
if (type === 'markdown') {
return [nonMarkdownLeaf];
}
return [];
})
);
const refreshBacklinkPanels = privateMethod(plugin, 'refreshBacklinkPanels');
await refreshBacklinkPanels();
});
it('should skip MarkdownView leaves without backlinks', async () => {
const { app, plugin } = createTestPlugin();
Reflect.set(plugin, 'reloadBacklinksView', vi.fn().mockResolvedValue(undefined));
const leaf = WorkspaceLeaf.create2__(app);
const mdView = MarkdownView.create2__(leaf);
Reflect.set(bypassStrictProxy(mdView), 'backlinks', undefined);
Reflect.set(bypassStrictProxy(leaf), 'view', mdView);
Reflect.set(
plugin.app.workspace,
'getLeavesOfType',
vi.fn().mockImplementation((type: string) => {
if (type === 'markdown') {
return [leaf.asOriginalType__()];
}
return [];
})
);
const refreshBacklinkPanels = privateMethod(plugin, 'refreshBacklinkPanels');
await refreshBacklinkPanels();
});
it('should recompute backlinks for MarkdownView leaves with backlinks', async () => {
const { app, plugin } = createTestPlugin();
Reflect.set(plugin, 'reloadBacklinksView', vi.fn().mockResolvedValue(undefined));
const leaf = WorkspaceLeaf.create2__(app);
const mdView = MarkdownView.create2__(leaf);
const mockFile = createMockFile('test2.md');
const recomputeBacklink = vi.fn();
Reflect.set(bypassStrictProxy(mdView), 'backlinks', { file: mockFile, recomputeBacklink });
Reflect.set(bypassStrictProxy(leaf), 'view', mdView);
Reflect.set(
plugin.app.workspace,
'getLeavesOfType',
vi.fn().mockImplementation((type: string) => {
if (type === 'markdown') {
return [leaf.asOriginalType__()];
}
return [];
})
);
const refreshBacklinkPanels = privateMethod(plugin, 'refreshBacklinkPanels');
await refreshBacklinkPanels();
expect(recomputeBacklink).toHaveBeenCalledWith(mockFile);
});
});
});

View file

@ -4,11 +4,6 @@
* Main plugin class for the Backlink Full Path plugin.
*/
import type {
App,
PluginManifest,
TFile
} from 'obsidian';
import type {
BacklinkPlugin,
BacklinkView,
@ -16,21 +11,27 @@ import type {
ResultDomItem,
ResultDomResult
} from '@obsidian-typings/obsidian-public-latest';
import type {
App,
PluginManifest,
TFile
} from 'obsidian';
import {
InternalPluginName,
ViewType
} from '@obsidian-typings/obsidian-public-latest/implementations';
import {
MarkdownView,
setTooltip
} from 'obsidian';
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 { MonkeyAroundComponent } from 'obsidian-dev-utils/obsidian/components/monkey-around-component';
import { PluginSettingsTabComponent } from 'obsidian-dev-utils/obsidian/components/plugin-settings-tab-component';
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin';
import { PluginDataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import {
InternalPluginName,
ViewType
} from '@obsidian-typings/obsidian-public-latest/implementations';
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin';
import { PluginEventSourceImpl } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
@ -55,39 +56,45 @@ export class Plugin extends PluginBase {
public constructor(app: App, manifest: PluginManifest) {
super(app, manifest);
this.pluginSettingsComponent = this.addChild(new PluginSettingsComponent(new PluginDataHandler(this)));
this.addChild(new PluginSettingsTabComponent({
plugin: this,
pluginSettingsTab: new PluginSettingsTab({
plugin: this,
settingsComponent: this.pluginSettingsComponent
this.pluginSettingsComponent = this.addChild(
new PluginSettingsComponent({
dataHandler: new PluginDataHandler(this),
pluginEventSource: new PluginEventSourceImpl(this)
})
}));
);
this.addChild(
new PluginSettingsTabComponent({
plugin: this,
pluginSettingsTab: new PluginSettingsTab({
plugin: this,
pluginSettingsComponent: this.pluginSettingsComponent
})
})
);
}
/**
* Called after pre-loaded components are initialized.
*/
protected override async onloadImpl(): Promise<void> {
await super.onloadImpl();
this.pluginSettingsComponent.on('saveSettings', () => {
invokeAsyncSafely(() => this.refreshBacklinkPanels());
public override onload(): void {
this.pluginSettingsComponent.on('saveSettings', async () => {
await this.refreshBacklinkPanels();
});
}
/**
* Called when the workspace layout is ready.
*/
protected override async onLayoutReady(): Promise<void> {
protected async onLayoutReady(): Promise<void> {
const backlinksCorePlugin = this.app.internalPlugins.getPluginById(InternalPluginName.Backlink);
if (!backlinksCorePlugin) {
return;
}
const that = this;
registerPatch(this, getPrototypeOf(backlinksCorePlugin.instance), {
const patch = this.addChild(new MonkeyAroundComponent());
patch.registerPatch(getPrototypeOf(backlinksCorePlugin.instance), {
onUserEnable: (next: () => void) => {
return function onUserEnablePatched(this: BacklinkPlugin): void {
next.call(this);
@ -204,7 +211,8 @@ export class Plugin extends PluginBase {
}
const that = this;
registerPatch(this, getPrototypeOf(backlinkView.backlink.backlinkDom), {
const patch = this.addChild(new MonkeyAroundComponent());
patch.registerPatch(getPrototypeOf(backlinkView.backlink.backlinkDom), {
addResult: (next: AddResultFn): AddResultFn => {
return function addResultPatched(this: ResultDom, file: TFile, result: ResultDomResult, content: string, shouldShowTitle?: boolean): ResultDomItem {
return that.addResult(next, this, file, result, content, shouldShowTitle);

View file

@ -10,7 +10,7 @@
"inlineSources": true,
"lib": [
"DOM",
"DOM.Iterable",
"DOM.Iterable",
"ES2024"
],
"libReplacement": true,