feat: prevent self-linking

This commit is contained in:
Kodai Nakamura 2025-11-27 12:00:00 +09:00
parent 0220c86c9f
commit f0670fc589
5 changed files with 295 additions and 2 deletions

View file

@ -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);

View file

@ -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]]");
});
});
});

View file

@ -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) {

View file

@ -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)
};

View file

@ -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")