From 19468ef360f3e18561d654eb6a7dd80a0a841ce4 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 17:51:59 -0400 Subject: [PATCH 1/5] ENG-1249 Add a cached relations index and link resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading relations from disk costs a full vault file read plus a JSON parse per call. That is fine for the Discourse Context panel, which asks once per file open, but not for anything that renders per link. RelationsIndex keeps a parsed snapshot grouped by endpoint id so a render path can ask synchronously, rebuilt from vault events so it covers our own writes and edits arriving over sync alike. resolveDiscourseLinkTarget answers, for one link, whether it points at a discourse node and how many relations that node has — from in-memory caches only, avoiding getNodeTypeIdForFile, which polls 500ms waiting on frontmatter. Link parsing moves to internalLinkParsing, which wikilinkDragHandler had its own byte-identical copy of. Nothing consumes this yet; the overlay that does follows. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/index.ts | 6 + .../src/utils/discourseLinkFrontmatter.ts | 40 +++++++ apps/obsidian/src/utils/discourseLinkUtils.ts | 58 +++++++++ .../obsidian/src/utils/internalLinkParsing.ts | 21 ++++ .../src/utils/relationsEndpointIndex.ts | 54 +++++++++ apps/obsidian/src/utils/relationsIndex.ts | 112 ++++++++++++++++++ .../obsidian/src/utils/wikilinkDragHandler.ts | 30 +---- 7 files changed, 293 insertions(+), 28 deletions(-) create mode 100644 apps/obsidian/src/utils/discourseLinkFrontmatter.ts create mode 100644 apps/obsidian/src/utils/discourseLinkUtils.ts create mode 100644 apps/obsidian/src/utils/internalLinkParsing.ts create mode 100644 apps/obsidian/src/utils/relationsEndpointIndex.ts create mode 100644 apps/obsidian/src/utils/relationsIndex.ts diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index d8488577d..12f66ad2a 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -39,6 +39,7 @@ import { NodeTagSuggestPopover } from "~/components/NodeTagSuggestModal"; import { InlineNodeTypePicker } from "~/components/InlineNodeTypePicker"; import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase"; import { FileChangeListener } from "~/utils/fileChangeListener"; +import { RelationsIndex } from "~/utils/relationsIndex"; import generateUid from "~/utils/generateUid"; import { migrateFrontmatterRelationsToRelationsJson, @@ -55,6 +56,7 @@ import { export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; + relationsIndex: RelationsIndex = new RelationsIndex(this); private tagStyleManager: DiscourseTagStyleManager | null = null; private fileChangeListener: FileChangeListener | null = null; private activeNodePopover: @@ -102,6 +104,8 @@ export default class DiscourseGraphPlugin extends Plugin { } } + this.relationsIndex.initialize(); + registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); addIcon(DISCOURSE_GRAPH_LOGO_ICON_ID, WHITE_LOGO_SVG); @@ -496,5 +500,7 @@ export default class DiscourseGraphPlugin extends Plugin { this.fileChangeListener.cleanup(); this.fileChangeListener = null; } + + this.relationsIndex.unload(); } } diff --git a/apps/obsidian/src/utils/discourseLinkFrontmatter.ts b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts new file mode 100644 index 000000000..168235760 --- /dev/null +++ b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts @@ -0,0 +1,40 @@ +import type { RelationInstance } from "~/types"; + +const asString = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +export const getNodeTypeIdFromFrontmatter = ( + frontmatter: Record | undefined, +): string | undefined => asString(frontmatter?.nodeTypeId); + +/** An imported node is referenced by both its nodeInstanceId and its importedFromRid. */ +export const getEndpointIdsFromFrontmatter = ( + frontmatter: Record | undefined, +): string[] => { + const endpointIds: string[] = []; + const nodeInstanceId = asString(frontmatter?.nodeInstanceId); + const importedFromRid = asString(frontmatter?.importedFromRid); + + if (nodeInstanceId) endpointIds.push(nodeInstanceId); + if (importedFromRid && importedFromRid !== nodeInstanceId) { + endpointIds.push(importedFromRid); + } + + return endpointIds; +}; + +/** + * Counts what the panel would list. Excludes unaccepted imports and relations + * orphaned by a deleted relation type, both of which the panel hides. + */ +export const countDisplayableRelations = ({ + relations, + isConfiguredType, +}: { + relations: RelationInstance[]; + isConfiguredType: (relationTypeId: string) => boolean; +}): number => + relations.filter( + (relation) => + relation.tentative !== false && isConfiguredType(relation.type), + ).length; diff --git a/apps/obsidian/src/utils/discourseLinkUtils.ts b/apps/obsidian/src/utils/discourseLinkUtils.ts new file mode 100644 index 000000000..28cf5e873 --- /dev/null +++ b/apps/obsidian/src/utils/discourseLinkUtils.ts @@ -0,0 +1,58 @@ +import { parseLinktext, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { DiscourseNode } from "~/types"; +import { getNodeTypeById, getRelationTypeById } from "./typeUtils"; +import { + countDisplayableRelations, + getEndpointIdsFromFrontmatter, + getNodeTypeIdFromFrontmatter, +} from "./discourseLinkFrontmatter"; + +export type DiscourseLinkTarget = { + file: TFile; + nodeType: DiscourseNode; + relationCount: number; +}; + +/** + * Resolves a link to a discourse node and its relation count from in-memory + * caches only; avoids getNodeTypeIdForFile, which polls 500ms for frontmatter. + */ +export const resolveDiscourseLinkTarget = ({ + plugin, + linktext, + sourcePath, +}: { + plugin: DiscourseGraphPlugin; + linktext: string; + sourcePath: string; +}): DiscourseLinkTarget | null => { + // Strips any #heading or #^block subpath. + const { path } = parseLinktext(linktext); + if (!path) return null; + + const file = plugin.app.metadataCache.getFirstLinkpathDest(path, sourcePath); + if (!file) return null; + + const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; + + const nodeTypeId = getNodeTypeIdFromFrontmatter(frontmatter); + if (!nodeTypeId) return null; + + const nodeType = getNodeTypeById(plugin, nodeTypeId); + if (!nodeType) return null; + + const endpointIds = getEndpointIdsFromFrontmatter(frontmatter); + if (endpointIds.length === 0) return { file, nodeType, relationCount: 0 }; + + const relations = + plugin.relationsIndex.getRelationsForEndpointIds(endpointIds); + + const relationCount = countDisplayableRelations({ + relations, + isConfiguredType: (relationTypeId) => + !!getRelationTypeById(plugin, relationTypeId), + }); + + return { file, nodeType, relationCount }; +}; diff --git a/apps/obsidian/src/utils/internalLinkParsing.ts b/apps/obsidian/src/utils/internalLinkParsing.ts new file mode 100644 index 000000000..d95edb4f3 --- /dev/null +++ b/apps/obsidian/src/utils/internalLinkParsing.ts @@ -0,0 +1,21 @@ +// Shared by the CM6 extensions that scan raw markdown for internal links. + +/** Embeds are not matched: the leading `!` sits outside, so callers check it. */ +export const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g; + +/** Target of a wikilink or markdown link; any `#subpath` is left for parseLinktext. */ +export const extractLinktext = (match: string): string => { + if (match.startsWith("[[")) { + const inner = match.slice(2, -2); + const pipeIndex = inner.indexOf("|"); + return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner; + } + + const parenOpen = match.lastIndexOf("("); + const rawPath = match.slice(parenOpen + 1, -1); + try { + return decodeURIComponent(rawPath); + } catch { + return rawPath; + } +}; diff --git a/apps/obsidian/src/utils/relationsEndpointIndex.ts b/apps/obsidian/src/utils/relationsEndpointIndex.ts new file mode 100644 index 000000000..f200a392e --- /dev/null +++ b/apps/obsidian/src/utils/relationsEndpointIndex.ts @@ -0,0 +1,54 @@ +import type { RelationInstance } from "~/types"; + +/** + * Groups relations by the ids at either end, so a lookup is a Map hit rather + * than a scan. Self-relations are filed once, not twice. + */ +export const buildEndpointIndex = ( + relations: Record, +): Map => { + const index = new Map(); + + const fileUnder = (endpointId: string, relation: RelationInstance): void => { + const existing = index.get(endpointId); + if (existing) { + existing.push(relation); + return; + } + index.set(endpointId, [relation]); + }; + + for (const relation of Object.values(relations)) { + if (!relation) continue; + if (relation.source) fileUnder(relation.source, relation); + if (relation.destination && relation.destination !== relation.source) { + fileUnder(relation.destination, relation); + } + } + + return index; +}; + +/** Relations touching any of `endpointIds`, deduped: an imported node matches on two ids. */ +export const collectRelations = ({ + index, + endpointIds, +}: { + index: Map; + endpointIds: Iterable; +}): RelationInstance[] => { + const seen = new Set(); + const collected: RelationInstance[] = []; + + for (const endpointId of endpointIds) { + const relations = index.get(endpointId); + if (!relations) continue; + for (const relation of relations) { + if (seen.has(relation.id)) continue; + seen.add(relation.id); + collected.push(relation); + } + } + + return collected; +}; diff --git a/apps/obsidian/src/utils/relationsIndex.ts b/apps/obsidian/src/utils/relationsIndex.ts new file mode 100644 index 000000000..64b154087 --- /dev/null +++ b/apps/obsidian/src/utils/relationsIndex.ts @@ -0,0 +1,112 @@ +import { TAbstractFile, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { RelationInstance } from "~/types"; +import { getRelationsFilePath, loadRelations } from "./relationsStore"; +import { buildEndpointIndex, collectRelations } from "./relationsEndpointIndex"; + +/** + * Parsed snapshot of relations.json so a render path can ask synchronously, + * rebuilt from vault events (which covers our own writes and sync alike). + */ +export class RelationsIndex { + private plugin: DiscourseGraphPlugin; + private index: Map | null = null; + private inFlight: Promise | null = null; + private stale = false; + private unloaded = false; + /** Lets a ViewPlugin, which only sees transactions, detect a changed snapshot. */ + private version = 0; + private subscribers = new Set<() => void>(); + /** Guards against a load that started before an invalidation overwriting a newer one. */ + private generation = 0; + + constructor(plugin: DiscourseGraphPlugin) { + this.plugin = plugin; + } + + initialize(): void { + const invalidateIfRelationsFile = (file: TAbstractFile): void => { + if (!(file instanceof TFile)) return; + if (file.path !== getRelationsFilePath()) return; + this.invalidate(); + }; + + const { vault } = this.plugin.app; + this.plugin.registerEvent(vault.on("modify", invalidateIfRelationsFile)); + this.plugin.registerEvent(vault.on("create", invalidateIfRelationsFile)); + this.plugin.registerEvent(vault.on("delete", invalidateIfRelationsFile)); + + void this.ensureLoaded(); + } + + unload(): void { + this.unloaded = true; + this.subscribers.clear(); + this.index = null; + this.inFlight = null; + this.generation += 1; + } + + /** Changes whenever the snapshot is replaced; see the field comment. */ + getVersion(): number { + return this.version; + } + + /** Fires when the snapshot changes. Returns an unsubscribe function. */ + onChange(subscriber: () => void): () => void { + this.subscribers.add(subscriber); + return () => this.subscribers.delete(subscriber); + } + + async ensureLoaded(): Promise { + if (this.unloaded) return; + if (this.index !== null && !this.stale) return; + if (this.inFlight) return this.inFlight; + + const generation = this.generation; + this.inFlight = (async () => { + try { + const relationsFile = await loadRelations(this.plugin); + // Superseded mid-read; the invalidation already scheduled a reload. + if (generation !== this.generation || this.unloaded) return; + this.index = buildEndpointIndex(relationsFile.relations ?? {}); + this.stale = false; + this.version += 1; + } finally { + // Every path, or ensureLoaded hands out a settled promise forever. + this.inFlight = null; + } + // The skipped invalidation above still needs a load of its own. + if (this.stale && !this.unloaded) { + void this.ensureLoaded(); + return; + } + this.notify(); + })(); + + return this.inFlight; + } + + /** + * Empty while cold, so treat that as "not loaded yet", not "no relations". + * Never schedules a load: that would make notify -> re-render -> read loop. + */ + getRelationsForEndpointIds( + endpointIds: Iterable, + ): RelationInstance[] { + if (this.index === null) return []; + return collectRelations({ index: this.index, endpointIds }); + } + + /** Keeps the old snapshot while reloading, so badges do not flash to 0. */ + private invalidate(): void { + this.generation += 1; + this.inFlight = null; + this.stale = true; + void this.ensureLoaded(); + } + + private notify(): void { + for (const subscriber of this.subscribers) subscriber(); + } +} diff --git a/apps/obsidian/src/utils/wikilinkDragHandler.ts b/apps/obsidian/src/utils/wikilinkDragHandler.ts index 12872979f..a1cde6245 100644 --- a/apps/obsidian/src/utils/wikilinkDragHandler.ts +++ b/apps/obsidian/src/utils/wikilinkDragHandler.ts @@ -10,6 +10,7 @@ import { import { TFile, WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; import type DiscourseGraphPlugin from "~/index"; +import { extractLinktext, INTERNAL_LINK_RE } from "./internalLinkParsing"; const buildObsidianUrl = (vaultName: string, filePath: string): string => { return `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}`; @@ -42,29 +43,6 @@ const setDragData = ( // --- Live Preview --- -/** - * Extract the file path from a link match. - * Handles wikilinks (`[[path]]`, `[[path|alias]]`) and - * markdown links (`[text](path.md)`), decoding URL-encoded paths. - */ -const extractLinkPath = (match: string): string => { - // Wikilink: [[path]] or [[path|alias]] - if (match.startsWith("[[")) { - const inner = match.slice(2, -2); - const pipeIndex = inner.indexOf("|"); - return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner; - } - - // Markdown link: [text](path) - const parenOpen = match.lastIndexOf("("); - const rawPath = match.slice(parenOpen + 1, -1); - try { - return decodeURIComponent(rawPath); - } catch (error) { - return rawPath; - } -}; - /** * Widget that renders a small drag handle next to an internal link. * CM6 widgets get `ignoreEvent() → true` by default, which means @@ -103,10 +81,6 @@ class WikilinkDragHandleWidget extends WidgetType { } } -// Matches wikilinks [[...]] and markdown links [text](path.md). -// Embed exclusion (![[...]] and ![text](...)) is handled in the loop. -const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g; - const hasVisibleCanvasLeaf = (plugin: DiscourseGraphPlugin): boolean => plugin.app.workspace .getLeavesOfType(VIEW_TYPE_TLDRAW_DG_PREVIEW) @@ -133,7 +107,7 @@ const buildWidgetDecorations = ( view.state.doc.sliceString(checkPos, checkPos + 1) === "!"; if (isEmbed) continue; const matchEnd = from + match.index + match[0].length; - const linkPath = extractLinkPath(match[0]); + const linkPath = extractLinktext(match[0]); const widget = new WikilinkDragHandleWidget(linkPath, plugin); widgets.push(Decoration.widget({ widget, side: 1 }).range(matchEnd)); } From ebaade53965a4106c6d3749ebcd525b0e4d81b32 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 18:08:11 -0400 Subject: [PATCH 2/5] ENG-1249 Handle relations.json renames and markdown subpath links Two review findings: Renaming relations.json out of the root, or another file into it, left the snapshot stale; only modify/create/delete were watched. Markdown links with a #heading or #^block subpath never matched, since the pattern required .md immediately before the closing paren. Wikilinks with subpaths already worked, so the two forms behaved differently. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/internalLinkParsing.ts | 3 ++- apps/obsidian/src/utils/relationsIndex.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/utils/internalLinkParsing.ts b/apps/obsidian/src/utils/internalLinkParsing.ts index d95edb4f3..e23c38757 100644 --- a/apps/obsidian/src/utils/internalLinkParsing.ts +++ b/apps/obsidian/src/utils/internalLinkParsing.ts @@ -1,7 +1,8 @@ // Shared by the CM6 extensions that scan raw markdown for internal links. /** Embeds are not matched: the leading `!` sits outside, so callers check it. */ -export const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g; +export const INTERNAL_LINK_RE = + /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g; /** Target of a wikilink or markdown link; any `#subpath` is left for parseLinktext. */ export const extractLinktext = (match: string): string => { diff --git a/apps/obsidian/src/utils/relationsIndex.ts b/apps/obsidian/src/utils/relationsIndex.ts index 64b154087..b5310bd01 100644 --- a/apps/obsidian/src/utils/relationsIndex.ts +++ b/apps/obsidian/src/utils/relationsIndex.ts @@ -35,6 +35,13 @@ export class RelationsIndex { this.plugin.registerEvent(vault.on("modify", invalidateIfRelationsFile)); this.plugin.registerEvent(vault.on("create", invalidateIfRelationsFile)); this.plugin.registerEvent(vault.on("delete", invalidateIfRelationsFile)); + // Both directions: the file moving out of the root, and one moving in. + this.plugin.registerEvent( + vault.on("rename", (file, oldPath) => { + if (oldPath === getRelationsFilePath()) this.invalidate(); + else invalidateIfRelationsFile(file); + }), + ); void this.ensureLoaded(); } From 834dfc9d0abc38a94166dbc7ddd67e6c28b78b4c Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 21:00:02 -0400 Subject: [PATCH 3/5] ENG-1249 Only clear inFlight when still the current load Review finding: an invalidation arriving mid-read starts a newer load, but the superseded load's finally cleared inFlight unconditionally, discarding the newer load's tracking. Later callers then saw no load in flight and started redundant ones. Clearing is now gated on the generation still matching, which is the same signal that decides whether the load's result is worth keeping. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/relationsIndex.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/utils/relationsIndex.ts b/apps/obsidian/src/utils/relationsIndex.ts index b5310bd01..5d087c06d 100644 --- a/apps/obsidian/src/utils/relationsIndex.ts +++ b/apps/obsidian/src/utils/relationsIndex.ts @@ -80,8 +80,9 @@ export class RelationsIndex { this.stale = false; this.version += 1; } finally { - // Every path, or ensureLoaded hands out a settled promise forever. - this.inFlight = null; + // Only if still the current load: an invalidation mid-read starts a + // newer one, and clearing unconditionally would discard its tracking. + if (generation === this.generation) this.inFlight = null; } // The skipped invalidation above still needs a load of its own. if (this.stale && !this.unloaded) { From 4dad52655aab39c8a254326c84e1bd44b9ef7d4f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 8 Sep 2026 17:28:36 -0400 Subject: [PATCH 4/5] ENG-1249 Trim a comment that restated its function name Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/discourseLinkUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/utils/discourseLinkUtils.ts b/apps/obsidian/src/utils/discourseLinkUtils.ts index 28cf5e873..189523fca 100644 --- a/apps/obsidian/src/utils/discourseLinkUtils.ts +++ b/apps/obsidian/src/utils/discourseLinkUtils.ts @@ -15,8 +15,8 @@ export type DiscourseLinkTarget = { }; /** - * Resolves a link to a discourse node and its relation count from in-memory - * caches only; avoids getNodeTypeIdForFile, which polls 500ms for frontmatter. + * In-memory caches only, so this can run per link on a render path. Avoids + * getNodeTypeIdForFile, which polls 500ms waiting on frontmatter. */ export const resolveDiscourseLinkTarget = ({ plugin, From 015cabe772f90a278c6615d63413cd72490c77e7 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 15 Sep 2026 22:05:57 -0400 Subject: [PATCH 5/5] ENG-2248 Strip link subpaths before resolving drag targets A link like [[note#heading]] or [label](note.md#heading) reached getFirstLinkpathDest with the subpath attached, so the drag handle appeared but its dragstart resolved nothing and cancelled. Also correct countDisplayableRelations' contract: it does not verify the peer endpoint still resolves to a file. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/discourseLinkFrontmatter.ts | 6 ++++-- apps/obsidian/src/utils/wikilinkDragHandler.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/obsidian/src/utils/discourseLinkFrontmatter.ts b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts index 168235760..b2eab65e5 100644 --- a/apps/obsidian/src/utils/discourseLinkFrontmatter.ts +++ b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts @@ -24,8 +24,10 @@ export const getEndpointIdsFromFrontmatter = ( }; /** - * Counts what the panel would list. Excludes unaccepted imports and relations - * orphaned by a deleted relation type, both of which the panel hides. + * Excludes unaccepted imports and relations orphaned by a deleted relation + * type, both of which the panel hides. Does not check that the peer endpoint + * still resolves to a file, so a deleted peer over-counts by one: resolving a + * peer is a vault scan, which callers on a render path cannot afford. */ export const countDisplayableRelations = ({ relations, diff --git a/apps/obsidian/src/utils/wikilinkDragHandler.ts b/apps/obsidian/src/utils/wikilinkDragHandler.ts index a1cde6245..f24b4baed 100644 --- a/apps/obsidian/src/utils/wikilinkDragHandler.ts +++ b/apps/obsidian/src/utils/wikilinkDragHandler.ts @@ -7,7 +7,7 @@ import { type DecorationSet, EditorView, } from "@codemirror/view"; -import { TFile, WorkspaceLeaf } from "obsidian"; +import { parseLinktext, TFile, WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; import type DiscourseGraphPlugin from "~/index"; import { extractLinktext, INTERNAL_LINK_RE } from "./internalLinkParsing"; @@ -23,8 +23,12 @@ const resolveFileFromLinkText = ( const activeFile = plugin.app.workspace.getActiveFile(); if (!activeFile) return null; + // getFirstLinkpathDest takes a link path, so any #heading or #^block must go. + const { path } = parseLinktext(linkText); + if (!path) return null; + const resolved = plugin.app.metadataCache.getFirstLinkpathDest( - linkText, + path, activeFile.path, ); return resolved instanceof TFile ? resolved : null;