From f0670fc58951de8f82047d5a5fc7452cd1a1f06b Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Thu, 27 Nov 2025 12:00:00 +0900 Subject: [PATCH] feat: prevent self-linking --- src/main.ts | 6 + ...replace-links.prevent-self-linking.test.ts | 223 ++++++++++++++++++ src/replace-links/replace-links.ts | 51 +++- src/settings/settings-info.ts | 2 + src/settings/settings.ts | 15 ++ 5 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 src/replace-links/__tests__/replace-links.prevent-self-linking.test.ts diff --git a/src/main.ts b/src/main.ts index ba06655..cc045ee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -126,6 +126,8 @@ export default class AutomaticLinkerPlugin extends Plugin { namespaceResolution: this.settings.namespaceResolution, baseDir: this.settings.baseDir, ignoreDateFormats: this.settings.ignoreDateFormats, + ignoreCase: this.settings.ignoreCase, + preventSelfLinking: this.settings.preventSelfLinking, }, }); @@ -176,6 +178,8 @@ export default class AutomaticLinkerPlugin extends Plugin { namespaceResolution: this.settings.namespaceResolution, baseDir: this.settings.baseDir, ignoreDateFormats: this.settings.ignoreDateFormats, + ignoreCase: this.settings.ignoreCase, + preventSelfLinking: this.settings.preventSelfLinking, }, }); if (this.settings.formatGitHubURLs) { @@ -266,6 +270,8 @@ export default class AutomaticLinkerPlugin extends Plugin { namespaceResolution: this.settings.namespaceResolution, baseDir: this.settings.baseDir, ignoreDateFormats: this.settings.ignoreDateFormats, + ignoreCase: this.settings.ignoreCase, + preventSelfLinking: this.settings.preventSelfLinking, }, }); cm.replaceSelection(updatedText); diff --git a/src/replace-links/__tests__/replace-links.prevent-self-linking.test.ts b/src/replace-links/__tests__/replace-links.prevent-self-linking.test.ts new file mode 100644 index 0000000..aaaf67e --- /dev/null +++ b/src/replace-links/__tests__/replace-links.prevent-self-linking.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { replaceLinks } from "../replace-links"; +import { buildCandidateTrieForTest } from "./test-helpers"; + +describe("replaceLinks - prevent self-linking", () => { + describe("when preventSelfLinking is true", () => { + it("should not link text to its own file", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "VPN" }, { path: "Welcome" }], + settings: { + restrictNamespace: false, + baseDir: undefined, + }, + }); + + // In VPN.md file, "VPN" should not be linked + const result = replaceLinks({ + body: "This is a note about VPN", + linkResolverContext: { + filePath: "VPN", + trie, + candidateMap, + }, + settings: { + preventSelfLinking: true, + }, + }); + expect(result).toBe("This is a note about VPN"); + }); + + it("should link text in other files", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "VPN" }, { path: "Welcome" }], + settings: { + restrictNamespace: false, + baseDir: undefined, + }, + }); + + // In Welcome.md file, "VPN" should be linked + const result = replaceLinks({ + body: "Here is my VPN Note", + linkResolverContext: { + filePath: "Welcome", + trie, + candidateMap, + }, + settings: { + preventSelfLinking: true, + }, + }); + expect(result).toBe("Here is my [[VPN]] Note"); + }); + + it("should handle files with namespaces", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "docs/VPN" }, { path: "docs/Welcome" }], + settings: { + restrictNamespace: false, + baseDir: undefined, + }, + }); + + // In docs/VPN.md file, "VPN" should not be linked + const result = replaceLinks({ + body: "This is about VPN", + linkResolverContext: { + filePath: "docs/VPN", + trie, + candidateMap, + }, + settings: { + preventSelfLinking: true, + }, + }); + expect(result).toBe("This is about VPN"); + }); + + it("should handle files with baseDir", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "pages/VPN" }, { path: "pages/Welcome" }], + settings: { + restrictNamespace: false, + baseDir: "pages", + }, + }); + + // In pages/VPN.md file, "VPN" should not be linked + const result = replaceLinks({ + body: "This is about VPN", + linkResolverContext: { + filePath: "pages/VPN", + trie, + candidateMap, + }, + settings: { + preventSelfLinking: true, + baseDir: "pages", + }, + }); + expect(result).toBe("This is about VPN"); + }); + + it("should handle multiple occurrences in the same file", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "VPN" }], + settings: { + restrictNamespace: false, + baseDir: undefined, + }, + }); + + const result = replaceLinks({ + body: "VPN is important. Always use VPN when connecting.", + linkResolverContext: { + filePath: "VPN", + trie, + candidateMap, + }, + settings: { + preventSelfLinking: true, + }, + }); + expect(result).toBe("VPN is important. Always use VPN when connecting."); + }); + }); + + describe("when preventSelfLinking is false", () => { + it("should link text to its own file (default behavior)", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "VPN" }], + settings: { + restrictNamespace: false, + baseDir: undefined, + }, + }); + + const result = replaceLinks({ + body: "This is about VPN", + linkResolverContext: { + filePath: "VPN", + trie, + candidateMap, + }, + settings: { + preventSelfLinking: false, + }, + }); + expect(result).toBe("This is about [[VPN]]"); + }); + + it("should link text to its own file when setting is undefined", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "VPN" }], + settings: { + restrictNamespace: false, + baseDir: undefined, + }, + }); + + const result = replaceLinks({ + body: "This is about VPN", + linkResolverContext: { + filePath: "VPN", + trie, + candidateMap, + }, + settings: {}, + }); + expect(result).toBe("This is about [[VPN]]"); + }); + }); + + describe("edge cases", () => { + it("should work with case-insensitive matching", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "VPN" }], + settings: { + restrictNamespace: false, + baseDir: undefined, + ignoreCase: true, + }, + }); + + const result = replaceLinks({ + body: "This is about vpn", + linkResolverContext: { + filePath: "VPN", + trie, + candidateMap, + }, + settings: { + preventSelfLinking: true, + ignoreCase: true, + }, + }); + expect(result).toBe("This is about vpn"); + }); + + it("should only prevent self-links, not other links", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "VPN" }, { path: "SSH" }, { path: "Networking" }], + settings: { + restrictNamespace: false, + baseDir: undefined, + }, + }); + + const result = replaceLinks({ + body: "VPN and SSH are both important for Networking", + linkResolverContext: { + filePath: "VPN", + trie, + candidateMap, + }, + settings: { + preventSelfLinking: true, + }, + }); + expect(result).toBe("VPN and [[SSH]] are both important for [[Networking]]"); + }); + }); +}); diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index f7fa5b4..83edb52 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -13,6 +13,7 @@ export interface ReplaceLinksSettings { baseDir?: string; ignoreDateFormats?: boolean; ignoreCase?: boolean; + preventSelfLinking?: boolean; } export interface ReplaceLinksOptions { @@ -138,7 +139,7 @@ const extractLinkParts = ( ): { linkPath: string; alias: string; hasAlias: boolean } => { const pipeIndex = canonicalPath.indexOf("|"); const hasAlias = pipeIndex !== -1; - + if (hasAlias) { const linkPath = canonicalPath.slice(0, pipeIndex); const alias = canonicalPath.slice(pipeIndex + 1); @@ -148,6 +149,27 @@ const extractLinkParts = ( return { linkPath: canonicalPath, alias: "", hasAlias }; }; +// Self-linking Prevention +const isSelfLink = ( + candidateData: CandidateData, + currentFilePath: string, + settings: ReplaceLinksSettings = {}, +): boolean => { + if (!settings.preventSelfLinking) { + return false; + } + + // Extract the link path from the canonical path + const { linkPath } = extractLinkParts(candidateData.canonical); + + // Normalize paths for comparison + const normalizedLinkPath = normalizeCanonicalPath(linkPath, settings.baseDir); + const normalizedCurrentPath = normalizeCanonicalPath(currentFilePath, settings.baseDir); + + // Compare the paths + return normalizedLinkPath === normalizedCurrentPath; +}; + // Link Content Creation const createLinkContent = ( candidateData: CandidateData, @@ -345,6 +367,14 @@ const processFallbackSearch = ( if (!bestCandidateData) return null; + // Check if this is a self-link and should be prevented + if (isSelfLink(bestCandidateData, filePath, settings)) { + return { + result: longestMatch.word, + newIndex: startIndex + longestMatch.length + }; + } + // Create the link const linkContent = createLinkContent(bestCandidateData, longestMatch.word, settings); const isInTable = isIndexInsideMarkdownTable(text, startIndex); @@ -362,6 +392,7 @@ const handleKoreanSpecialCases = ( i: number, candidate: string, candidateData: CandidateData, + filePath: string, settings: ReplaceLinksSettings = {}, ): { result: string; newIndex: number } | null => { const remaining = text.slice(i + candidate.length); @@ -369,9 +400,17 @@ const handleKoreanSpecialCases = ( // Special handling when followed by "이다" const suffixMatch = remaining.match(REGEX_PATTERNS.KOREAN_SUFFIX); if (suffixMatch) { + // Check if this is a self-link and should be prevented + if (isSelfLink(candidateData, filePath, settings)) { + return { + result: candidate + suffixMatch[0], + newIndex: i + candidate.length + suffixMatch[0].length, + }; + } + const linkContent = createLinkContent(candidateData, candidate, settings); const finalLink = formatFinalLink(linkContent, false); - + return { result: finalLink + suffixMatch[0], newIndex: i + candidate.length + suffixMatch[0].length, @@ -558,6 +597,13 @@ const processStandardText = ( ); if (candidateData) { + // Check if this is a self-link and should be prevented + if (isSelfLink(candidateData, filePath, settings)) { + result += candidate; + i += candidate.length; + continue outer; + } + // Handle Korean special cases const isKorean = isKoreanText(candidate); if (isKorean) { @@ -566,6 +612,7 @@ const processStandardText = ( i, candidate, candidateData, + filePath, settings, ); if (koreanResult) { diff --git a/src/settings/settings-info.ts b/src/settings/settings-info.ts index 174404a..2cebc2c 100644 --- a/src/settings/settings-info.ts +++ b/src/settings/settings-info.ts @@ -16,6 +16,7 @@ export type AutomaticLinkerSettings = { replaceUrlWithTitle: boolean; // Replace raw URLs with [Title](URL) replaceUrlWithTitleIgnoreDomains: string[]; // List of domains to ignore when replacing URLs with titles excludeDirsFromAutoLinking: string[]; // Optional: List of directories to exclude from auto-linking + preventSelfLinking: boolean; // Prevent linking text to its own file }; export const DEFAULT_SETTINGS: AutomaticLinkerSettings = { @@ -36,4 +37,5 @@ export const DEFAULT_SETTINGS: AutomaticLinkerSettings = { replaceUrlWithTitle: false, // Default: disable replacing URLs with titles replaceUrlWithTitleIgnoreDomains: [], excludeDirsFromAutoLinking: [], // Default: no excluded directories + preventSelfLinking: false, // Default: allow self-linking (backward compatibility) }; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 47e7254..732ffc6 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -138,6 +138,21 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab { }); }); + // Toggle for preventing self-linking + new Setting(containerEl) + .setName("Prevent self-linking") + .setDesc( + "When enabled, text will not be linked to its own file. For example, the word 'VPN' inside VPN.md will not be converted to a link.", + ) + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.preventSelfLinking) + .onChange(async (value) => { + this.plugin.settings.preventSelfLinking = value; + await this.plugin.saveData(this.plugin.settings); + }); + }); + // Add excluding dirs that you wish to exclude from the automatic linking new Setting(containerEl) .setName("Exclude directories from automatic linking")