mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
Merge pull request #2 from kdnk/refactor-tests
refactor: restructure replace-links module and improve namespace reso…
This commit is contained in:
commit
df44dd79ce
14 changed files with 2919 additions and 1633 deletions
|
|
@ -7,7 +7,8 @@
|
|||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"test": "VITE_CJS_IGNORE_WARNING=true vitest run"
|
||||
"test": "VITE_CJS_IGNORE_WARNING=true vitest run",
|
||||
"test:watch": "VITE_CJS_IGNORE_WARNING=true vitest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
|
|
|||
1631
src/replace-links.ts
1631
src/replace-links.ts
File diff suppressed because it is too large
Load diff
1396
src/replace-links/__tests__/prev.test.ts
Normal file
1396
src/replace-links/__tests__/prev.test.ts
Normal file
File diff suppressed because it is too large
Load diff
192
src/replace-links/__tests__/replace-links.alias.test.ts
Normal file
192
src/replace-links/__tests__/replace-links.alias.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
|
||||
describe("replaceLinks - alias handling", () => {
|
||||
describe("basic alias", () => {
|
||||
it("replaces alias", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["HelloWorld"],
|
||||
aliasMap: {
|
||||
HelloWorld: ["HW"],
|
||||
},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[HelloWorld|HW]]");
|
||||
});
|
||||
|
||||
it("prefers exact match over alias", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["HelloWorld", "HW"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[HW]]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("namespaced alias", () => {
|
||||
it("replaces namespaced alias", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/HelloWorld"],
|
||||
aliasMap: {
|
||||
"pages/HelloWorld": ["HW"],
|
||||
},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[pages/HelloWorld|HW]]");
|
||||
});
|
||||
|
||||
it("replaces multiple occurrences of alias and normal candidate (with baseDir)", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["HelloWorld"],
|
||||
aliasMap: {
|
||||
HelloWorld: ["Hello"],
|
||||
},
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "Hello HelloWorld",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[HelloWorld|Hello]] [[HelloWorld]]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alias with restrictNamespace", () => {
|
||||
it("respects restrictNamespace for alias", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/set/HelloWorld"],
|
||||
aliasMap: {
|
||||
"pages/set/HelloWorld": ["HW"],
|
||||
},
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/HelloWorld|HW]]");
|
||||
});
|
||||
|
||||
it("replace alias when restrictNamespace is false", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/set/HelloWorld"],
|
||||
aliasMap: {
|
||||
"pages/set/HelloWorld": ["HW"],
|
||||
},
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/HelloWorld|HW]]");
|
||||
});
|
||||
|
||||
it("does not replace alias when namespace does not match", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/set/HelloWorld"],
|
||||
aliasMap: {
|
||||
"pages/set/HelloWorld": ["HW"],
|
||||
},
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("HW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alias and baseDir", () => {
|
||||
it("should replace alias with baseDir", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/set/HelloWorld"],
|
||||
aliasMap: {
|
||||
"pages/set/HelloWorld": ["HW"],
|
||||
},
|
||||
restrictNamespace: false,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "HW",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/HelloWorld|HW]]");
|
||||
});
|
||||
});
|
||||
});
|
||||
199
src/replace-links/__tests__/replace-links.basic.test.ts
Normal file
199
src/replace-links/__tests__/replace-links.basic.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
|
||||
describe("replaceLinks", () => {
|
||||
describe("basic", () => {
|
||||
it("replaces links", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[[hello]]");
|
||||
});
|
||||
|
||||
it("replaces links with bullet", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "- hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("- [[hello]]");
|
||||
});
|
||||
|
||||
it("replaces links with number", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "1. hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("1. [[hello]]");
|
||||
});
|
||||
|
||||
it("does not replace links in code blocks", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "```\nhello\n```",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("```\nhello\n```");
|
||||
});
|
||||
|
||||
it("does not replace links in inline code", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "`hello`",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("`hello`");
|
||||
});
|
||||
|
||||
it("does not replace existing wikilinks", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "[[hello]]",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[[hello]]");
|
||||
});
|
||||
|
||||
it("does not replace existing markdown links", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "[hello](world)",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[hello](world)");
|
||||
});
|
||||
|
||||
it("respects minCharCount", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "hello",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 6 },
|
||||
});
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple links", () => {
|
||||
it("replaces multiple links in the same line", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello", "world"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "hello world",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[[hello]] [[world]]");
|
||||
});
|
||||
|
||||
it("replaces multiple links in different lines", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["hello", "world"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "hello\nworld",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { minCharCount: 0 },
|
||||
});
|
||||
expect(result).toBe("[[hello]]\n[[world]]");
|
||||
});
|
||||
});
|
||||
});
|
||||
159
src/replace-links/__tests__/replace-links.cjk.test.ts
Normal file
159
src/replace-links/__tests__/replace-links.cjk.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
|
||||
describe("replaceLinks - CJK handling", () => {
|
||||
describe("containing CJK", () => {
|
||||
it("unmatched namespace", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["namespace/タグ"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "namespace",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("namespace");
|
||||
});
|
||||
|
||||
it("multiple namespaces", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"namespace/tag1",
|
||||
"namespace/tag2",
|
||||
"namespace/タグ3",
|
||||
],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "namespace/tag1 namespace/tag2 namespace/タグ3",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[namespace/tag1]] [[namespace/tag2]] [[namespace/タグ3]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("starting CJK", () => {
|
||||
it("unmatched namespace", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["namespace/タグ"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "名前空間",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("名前空間");
|
||||
});
|
||||
|
||||
it("single namespace", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["名前空間/tag1", "名前空間/tag2", "名前空間/タグ3"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "名前空間/tag1",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[名前空間/tag1]]");
|
||||
});
|
||||
|
||||
it("multiple namespaces", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["名前空間/tag1", "名前空間/tag2", "名前空間/タグ3"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "名前空間/tag1 名前空間/tag2 名前空間/タグ3",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[名前空間/tag1]] [[名前空間/tag2]] [[名前空間/タグ3]]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("automatic-linker-restrict-namespace with CJK", () => {
|
||||
it("should respect restrictNamespace for CJK with baseDir", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/セット/タグ", "pages/other/current"],
|
||||
aliasMap: {
|
||||
"pages/セット/タグ": [],
|
||||
},
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "タグ",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/セット/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[セット/タグ]]");
|
||||
});
|
||||
|
||||
it("should not replace CJK when namespace does not match with baseDir", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/セット/タグ", "pages/other/current"],
|
||||
aliasMap: {
|
||||
"pages/セット/タグ": [],
|
||||
},
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "タグ",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("タグ");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
|
||||
describe("replaceLinks - namespace resolution", () => {
|
||||
describe("basic namespace resolution", () => {
|
||||
it("unmatched namespace", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["namespace/tag1", "namespace/tag2"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "namespace",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("namespace");
|
||||
});
|
||||
|
||||
it("single namespace", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["namespace/tag1", "namespace/tag2"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "namespace/tag1",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/tag1]]");
|
||||
});
|
||||
|
||||
it("multiple namespaces", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["namespace/tag1", "namespace/tag2", "namespace"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "namespace/tag1 namespace/tag2",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/tag1]] [[namespace/tag2]]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("namespace resolution nearest file path", () => {
|
||||
it("closest siblings namespace should be used", async () => {
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"namespace/a/b/c/d/link",
|
||||
"namespace/a/b/c/d/e/f/link",
|
||||
"namespace/a/b/c/link",
|
||||
],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
|
||||
const result = await replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/a/b/c/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true },
|
||||
});
|
||||
expect(result).toBe("[[namespace/a/b/c/link]]");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"namespace/a/b/c/link",
|
||||
"namespace/a/b/c/d/link",
|
||||
"namespace/a/b/c/d/e/f/link",
|
||||
],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/a/b/c/d/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true },
|
||||
});
|
||||
expect(result).toBe("[[namespace/a/b/c/d/link]]");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"namespace/xxx/link",
|
||||
"another-namespace/link",
|
||||
"another-namespace/a/b/c/link",
|
||||
"another-namespace/a/b/c/d/link",
|
||||
"another-namespace/a/b/c/d/e/f/link",
|
||||
],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true },
|
||||
});
|
||||
expect(result).toBe("[[namespace/xxx/link]]");
|
||||
}
|
||||
});
|
||||
|
||||
it("closest children namespace should be used", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"namespace1/subnamespace/link",
|
||||
"namespace2/super-super-long-long-directory/link",
|
||||
"namespace3/link",
|
||||
"namespace/a/b/c/link",
|
||||
"namespace/a/b/c/d/link",
|
||||
"namespace/a/b/c/d/e/f/link",
|
||||
],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/a/b/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true },
|
||||
});
|
||||
expect(result).toBe("[[namespace/a/b/c/link]]");
|
||||
});
|
||||
|
||||
it("find closest path if the current path is in base dir and the candidate is not", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"namespace1/aaaaaaaaaaaaaaaaaaaaaaaaa/link",
|
||||
"namespace1/link2",
|
||||
"namespace2/link2",
|
||||
"namespace3/aaaaaa/bbbbbb/link2",
|
||||
"base/looooooooooooooooooooooooooooooooooooooong/link",
|
||||
"base/looooooooooooooooooooooooooooooooooooooong/super-super-long-long-long-long-closest-sub-dir/link",
|
||||
"base/a/b/c/link",
|
||||
"base/a/b/c/d/link",
|
||||
"base/a/b/c/d/e/f/link",
|
||||
],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: "base",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "link link2",
|
||||
linkResolverContext: {
|
||||
filePath: "base/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: true, baseDir: "base" },
|
||||
});
|
||||
expect(result).toBe(
|
||||
"[[looooooooooooooooooooooooooooooooooooooong/link]] [[namespace1/link2]]",
|
||||
);
|
||||
|
||||
const result2 = await replaceLinks({
|
||||
body: "link link2",
|
||||
linkResolverContext: {
|
||||
filePath: "base/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: { namespaceResolution: false, baseDir: "base" },
|
||||
});
|
||||
expect(result2).toBe("link link2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("namespace resoluton with aliases", () => {
|
||||
it("should resolve without aliases", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"namespace/xx/yy/link",
|
||||
"namespace/xx/link",
|
||||
"namespace/link2",
|
||||
],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "link",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/xx/link]]");
|
||||
});
|
||||
|
||||
it("should resolve aliases", async () => {
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"namespace/xx/yy/link",
|
||||
"namespace/xx/link",
|
||||
"namespace/link2",
|
||||
],
|
||||
aliasMap: {
|
||||
"namespace/xx/link": ["alias"],
|
||||
},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "alias",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/xx/link|alias]]");
|
||||
}
|
||||
{
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"namespace/xx/yy/zz/link",
|
||||
"namespace/xx/yy/link",
|
||||
"namespace/xx/link",
|
||||
"namespace/link",
|
||||
"namespace/link2",
|
||||
],
|
||||
aliasMap: {
|
||||
"namespace/xx/yy/link": ["alias"],
|
||||
},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "alias",
|
||||
linkResolverContext: {
|
||||
filePath: "namespace/xx/yy/current-file",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/xx/yy/link|alias]]");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
113
src/replace-links/__tests__/replace-links.namespace.test.ts
Normal file
113
src/replace-links/__tests__/replace-links.namespace.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
|
||||
describe("replaceLinks - namespace resolution", () => {
|
||||
describe("complex fileNames", () => {
|
||||
it("unmatched namespace", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["namespace/tag1", "namespace/tag2"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "namespace",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("namespace");
|
||||
});
|
||||
|
||||
it("single namespace", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["namespace/tag1", "namespace/tag2"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "namespace/tag1",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/tag1]]");
|
||||
});
|
||||
|
||||
it("multiple namespaces", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["namespace/tag1", "namespace/tag2", "namespace"],
|
||||
aliasMap: {},
|
||||
restrictNamespace: false,
|
||||
baseDir: undefined,
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "namespace/tag1 namespace/tag2",
|
||||
linkResolverContext: {
|
||||
filePath: "journals/2022-01-01",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[namespace/tag1]] [[namespace/tag2]]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("automatic-linker-restrict-namespace and base dir", () => {
|
||||
it("should replace candidate with restrictNamespace when effective namespace matches", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/set/a", "pages/other/current"],
|
||||
aliasMap: {
|
||||
"pages/set/a": [],
|
||||
},
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "a",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/a]]");
|
||||
});
|
||||
|
||||
it("should not replace candidate with restrictNamespace when effective namespace does not match", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/set/a", "pages/other/current"],
|
||||
aliasMap: {
|
||||
"pages/set/a": [],
|
||||
},
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "a",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("a");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { replaceLinks } from "../replace-links";
|
||||
import { buildCandidateTrieForTest } from "./test-helpers";
|
||||
|
||||
describe("replaceLinks - restrict namespace", () => {
|
||||
describe("automatic-linker-restrict-namespace with baseDir", () => {
|
||||
it("should respect restrictNamespace with baseDir", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/set/tag", "pages/other/current"],
|
||||
aliasMap: {
|
||||
"pages/set/tag": [],
|
||||
},
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "tag",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set/tag]]");
|
||||
});
|
||||
|
||||
it("should not replace when namespace does not match with baseDir", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: ["pages/set/tag", "pages/other/current"],
|
||||
aliasMap: {
|
||||
"pages/set/tag": [],
|
||||
},
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
});
|
||||
const result = await replaceLinks({
|
||||
body: "tag",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/other/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("tag");
|
||||
});
|
||||
|
||||
it("should handle multiple namespaces with restrictNamespace", async () => {
|
||||
const { candidateMap, trie } = buildCandidateTrieForTest({
|
||||
fileNames: [
|
||||
"pages/set1/tag1",
|
||||
"pages/set2/tag2",
|
||||
"pages/other/current",
|
||||
],
|
||||
aliasMap: {},
|
||||
restrictNamespace: true,
|
||||
baseDir: "pages",
|
||||
});
|
||||
console.log(candidateMap);
|
||||
const result = await replaceLinks({
|
||||
body: "tag1 tag2",
|
||||
linkResolverContext: {
|
||||
filePath: "pages/set1/current",
|
||||
trie,
|
||||
candidateMap,
|
||||
},
|
||||
settings: {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: "pages",
|
||||
},
|
||||
});
|
||||
expect(result).toBe("[[set1/tag1]] tag2");
|
||||
});
|
||||
});
|
||||
});
|
||||
51
src/replace-links/__tests__/test-helpers.ts
Normal file
51
src/replace-links/__tests__/test-helpers.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { PathAndAliases } from "../../path-and-aliases.types";
|
||||
import { buildCandidateTrie } from "../../trie";
|
||||
import { getEffectiveNamespace } from "../replace-links";
|
||||
|
||||
type Path = string;
|
||||
type Alias = string;
|
||||
export const buildCandidateTrieForTest = ({
|
||||
fileNames,
|
||||
aliasMap,
|
||||
restrictNamespace,
|
||||
baseDir,
|
||||
}: {
|
||||
fileNames: string[];
|
||||
aliasMap: Record<Path, Alias[]>;
|
||||
restrictNamespace: boolean;
|
||||
baseDir: string | undefined;
|
||||
}) => {
|
||||
const files = getSortedFiles({
|
||||
fileNames,
|
||||
restrictNamespace,
|
||||
baseDir,
|
||||
});
|
||||
// register alias
|
||||
for (const file of files) {
|
||||
if (aliasMap[file.path]) {
|
||||
file.aliases = aliasMap[file.path];
|
||||
}
|
||||
}
|
||||
const { candidateMap, trie } = buildCandidateTrie(files, baseDir);
|
||||
return { candidateMap, trie };
|
||||
};
|
||||
|
||||
const getSortedFiles = ({
|
||||
fileNames,
|
||||
restrictNamespace = false,
|
||||
baseDir,
|
||||
}: {
|
||||
fileNames: string[];
|
||||
restrictNamespace?: boolean;
|
||||
baseDir?: string;
|
||||
}): PathAndAliases[] => {
|
||||
const sortedFileNames = fileNames
|
||||
.slice()
|
||||
.sort((a, b) => b.length - a.length);
|
||||
return sortedFileNames.map((path) => ({
|
||||
path,
|
||||
aliases: null,
|
||||
restrictNamespace: restrictNamespace ?? false,
|
||||
namespace: getEffectiveNamespace(path, baseDir),
|
||||
}));
|
||||
};
|
||||
2
src/replace-links/index.ts
Normal file
2
src/replace-links/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { getEffectiveNamespace, replaceLinks } from "./replace-links";
|
||||
export type { ReplaceLinksOptions } from "./types";
|
||||
425
src/replace-links/replace-links.ts
Normal file
425
src/replace-links/replace-links.ts
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import { CandidateData, TrieNode } from "../trie";
|
||||
|
||||
export const getEffectiveNamespace = (
|
||||
filePath: string,
|
||||
baseDir?: string,
|
||||
): string => {
|
||||
if (baseDir) {
|
||||
const prefix = baseDir + "/";
|
||||
if (filePath.startsWith(prefix)) {
|
||||
const rest = filePath.slice(prefix.length);
|
||||
const segments = rest.split("/");
|
||||
return segments[0] || "";
|
||||
}
|
||||
}
|
||||
const segments = filePath.split("/");
|
||||
return segments[0] || "";
|
||||
};
|
||||
|
||||
export const replaceLinks = async ({
|
||||
body,
|
||||
linkResolverContext: { filePath, trie, candidateMap },
|
||||
settings = {
|
||||
minCharCount: 0,
|
||||
namespaceResolution: true,
|
||||
baseDir: undefined,
|
||||
ignoreDateFormats: true,
|
||||
},
|
||||
}: {
|
||||
body: string;
|
||||
linkResolverContext: {
|
||||
filePath: string;
|
||||
trie: TrieNode;
|
||||
candidateMap: Map<string, CandidateData>;
|
||||
};
|
||||
settings?: {
|
||||
minCharCount?: number;
|
||||
namespaceResolution?: boolean;
|
||||
baseDir?: string;
|
||||
ignoreDateFormats?: boolean;
|
||||
};
|
||||
}): Promise<string> => {
|
||||
// Return the body unchanged if its length is below the minimum character count.
|
||||
if (body.length <= (settings.minCharCount ?? 0)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// Utility: Check if a character is a word boundary.
|
||||
const isWordBoundary = (char: string | undefined): boolean => {
|
||||
if (char === undefined) return true;
|
||||
return !/[\p{L}\p{N}_/-]/u.test(char);
|
||||
};
|
||||
|
||||
// Utility: Check if a candidate represents a month note (only digits from 1 to 12).
|
||||
const isMonthNote = (candidate: string): boolean =>
|
||||
!candidate.includes("/") &&
|
||||
/^[0-9]{1,2}$/.test(candidate) &&
|
||||
parseInt(candidate, 10) >= 1 &&
|
||||
parseInt(candidate, 10) <= 12;
|
||||
|
||||
// Regex to protect code blocks, inline code, wikilinks, and Markdown links.
|
||||
const protectedRegex =
|
||||
/(```[\s\S]*?```|`[^`]*`|\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))/g;
|
||||
|
||||
// Normalize the body text to NFC.
|
||||
body = body.normalize("NFC");
|
||||
|
||||
// If the body consists solely of a protected link, return it unchanged.
|
||||
if (/^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/.test(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// Precompute the fallback index: Map the candidate's shorthand (the substring after the last "/")
|
||||
// to an array of entries from candidateMap.
|
||||
const fallbackIndex = new Map<string, Array<[string, CandidateData]>>();
|
||||
for (const [key, data] of candidateMap.entries()) {
|
||||
const slashIndex = key.lastIndexOf("/");
|
||||
if (slashIndex === -1) continue;
|
||||
const shorthand = key.slice(slashIndex + 1);
|
||||
let arr = fallbackIndex.get(shorthand);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
fallbackIndex.set(shorthand, arr);
|
||||
}
|
||||
arr.push([key, data]);
|
||||
}
|
||||
|
||||
// Determine the effective namespace of the current file.
|
||||
const currentNamespace = settings.baseDir
|
||||
? getEffectiveNamespace(filePath, settings.baseDir)
|
||||
: (function () {
|
||||
const segments = filePath.split("/");
|
||||
return segments[0] || "";
|
||||
})();
|
||||
|
||||
// Helper function to process an unprotected text segment.
|
||||
const replaceInSegment = (text: string): string => {
|
||||
let result = "";
|
||||
let i = 0;
|
||||
outer: while (i < text.length) {
|
||||
// If a URL is found, copy it unchanged.
|
||||
const urlMatch = text.slice(i).match(/^(https?:\/\/[^\s]+)/);
|
||||
if (urlMatch) {
|
||||
result += urlMatch[0];
|
||||
i += urlMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use the trie to find a candidate.
|
||||
let node = trie;
|
||||
let lastCandidate: { candidate: string; length: number } | null =
|
||||
null;
|
||||
let j = i;
|
||||
while (j < text.length) {
|
||||
const ch = text[j];
|
||||
const child = node.children.get(ch);
|
||||
if (!child) break;
|
||||
node = child;
|
||||
if (node.candidate) {
|
||||
lastCandidate = {
|
||||
candidate: node.candidate,
|
||||
length: j - i + 1,
|
||||
};
|
||||
}
|
||||
j++;
|
||||
}
|
||||
if (lastCandidate) {
|
||||
const candidate = text.substring(i, i + lastCandidate.length);
|
||||
// If ignoreDateFormats is enabled and the candidate matches YYYY-MM-DD, skip conversion.
|
||||
if (
|
||||
settings.ignoreDateFormats &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(candidate)
|
||||
) {
|
||||
result += candidate;
|
||||
i += lastCandidate.length;
|
||||
continue outer;
|
||||
}
|
||||
// Skip conversion for month notes.
|
||||
if (isMonthNote(candidate)) {
|
||||
result += candidate;
|
||||
i += lastCandidate.length;
|
||||
continue;
|
||||
}
|
||||
if (candidateMap.has(candidate)) {
|
||||
const candidateData = candidateMap.get(candidate);
|
||||
// Although candidateMap.has(candidate) returned true, TypeScript still requires a check for undefined.
|
||||
if (!candidateData) {
|
||||
// If candidateData is not found, skip to the next iteration.
|
||||
continue outer;
|
||||
}
|
||||
|
||||
// Determine if the candidate is composed solely of CJK characters.
|
||||
const isCjkCandidate =
|
||||
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+$/u.test(
|
||||
candidate,
|
||||
);
|
||||
const isKorean = /^[\p{Script=Hangul}]+$/u.test(candidate);
|
||||
|
||||
// For non-CJK or Korean candidates, perform word boundary checks.
|
||||
if (!isCjkCandidate || isKorean) {
|
||||
if (isKorean) {
|
||||
const remaining = text.slice(i + candidate.length);
|
||||
const suffixMatch = remaining.match(/^(이다\.?)/);
|
||||
if (suffixMatch) {
|
||||
result +=
|
||||
`[[${candidateData.canonical}]]` +
|
||||
suffixMatch[0];
|
||||
i += candidate.length + suffixMatch[0].length;
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
const left = i > 0 ? text[i - 1] : undefined;
|
||||
const right =
|
||||
i + candidate.length < text.length
|
||||
? text[i + candidate.length]
|
||||
: undefined;
|
||||
if (!isWordBoundary(left) || !isWordBoundary(right)) {
|
||||
result += text[i];
|
||||
i++;
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
|
||||
// If namespace resolution is enabled and candidateData has a namespace restriction,
|
||||
// skip conversion if its namespace does not match the current namespace.
|
||||
if (
|
||||
settings.namespaceResolution &&
|
||||
candidateData.restrictNamespace &&
|
||||
candidateData.namespace !== currentNamespace
|
||||
) {
|
||||
result += candidate;
|
||||
i += candidate.length;
|
||||
continue outer;
|
||||
}
|
||||
|
||||
// Replace the candidate with the wikilink format.
|
||||
let linkPath = candidateData.canonical;
|
||||
const hasAlias = linkPath.includes("|");
|
||||
let alias = "";
|
||||
|
||||
if (hasAlias) {
|
||||
[linkPath, alias] = linkPath.split("|");
|
||||
}
|
||||
|
||||
// Remove pages/ prefix when baseDir is set
|
||||
if (settings.baseDir && linkPath.startsWith("pages/")) {
|
||||
linkPath = linkPath.slice("pages/".length);
|
||||
}
|
||||
|
||||
// Remove base/ prefix when in base directory
|
||||
if (settings.baseDir && linkPath.startsWith(settings.baseDir + "/")) {
|
||||
linkPath = linkPath.slice((settings.baseDir + "/").length);
|
||||
}
|
||||
|
||||
result += hasAlias ? `[[${linkPath}|${alias}]]` : `[[${linkPath}]]`;
|
||||
i += candidate.length;
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no candidate was found via the trie.
|
||||
if (settings.namespaceResolution) {
|
||||
const fallbackRegex = /^([\p{L}\p{N}_-]+)/u;
|
||||
const fallbackMatch = text.slice(i).match(fallbackRegex);
|
||||
if (fallbackMatch) {
|
||||
const word = fallbackMatch[1];
|
||||
|
||||
// If the word is in YYYY-MM-DD format and ignoreDateFormats is enabled, do not convert.
|
||||
if (
|
||||
settings.ignoreDateFormats &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(word)
|
||||
) {
|
||||
result += word;
|
||||
i += word.length;
|
||||
continue outer;
|
||||
}
|
||||
|
||||
// For date formats: if the word is two digits and the result ends with "YYYY-MM-",
|
||||
// skip conversion.
|
||||
if (/^\d{2}$/.test(word) && /\d{4}-\d{2}-$/.test(result)) {
|
||||
result += text[i];
|
||||
i++;
|
||||
continue outer;
|
||||
}
|
||||
|
||||
// Skip conversion for month notes.
|
||||
if (isMonthNote(word)) {
|
||||
result += word;
|
||||
i += word.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Quickly retrieve matching candidate entries using fallbackIndex.
|
||||
const candidateList = fallbackIndex.get(word);
|
||||
if (candidateList) {
|
||||
// Filter candidates that comply with the current namespace restrictions.
|
||||
const filteredCandidates = candidateList.filter(
|
||||
([, data]) =>
|
||||
!(
|
||||
data.restrictNamespace &&
|
||||
data.namespace !== currentNamespace
|
||||
),
|
||||
);
|
||||
|
||||
if (filteredCandidates.length === 1) {
|
||||
const candidateData = filteredCandidates[0][1];
|
||||
let linkPath = candidateData.canonical;
|
||||
// Remove pages/ prefix when baseDir is set
|
||||
if (settings.baseDir && linkPath.startsWith("pages/")) {
|
||||
linkPath = linkPath.slice("pages/".length);
|
||||
}
|
||||
|
||||
// Remove base/ prefix when in base directory
|
||||
if (settings.baseDir && linkPath.startsWith(settings.baseDir + "/")) {
|
||||
linkPath = linkPath.slice((settings.baseDir + "/").length);
|
||||
}
|
||||
result += `[[${linkPath}]]`;
|
||||
i += word.length;
|
||||
continue outer;
|
||||
} else if (filteredCandidates.length > 1) {
|
||||
let bestCandidate: [string, CandidateData] | null =
|
||||
null;
|
||||
let bestScore = -1;
|
||||
// Get the directory portion of the current file (if any)
|
||||
const filePathDir = filePath.includes("/")
|
||||
? filePath.slice(0, filePath.lastIndexOf("/"))
|
||||
: "";
|
||||
const filePathSegments = filePathDir
|
||||
? filePathDir.split("/")
|
||||
: [];
|
||||
for (const [key, data] of filteredCandidates) {
|
||||
const slashIndex = key.lastIndexOf("/");
|
||||
const candidateDir = key.slice(0, slashIndex);
|
||||
const candidateSegments =
|
||||
candidateDir.split("/");
|
||||
let score = 0;
|
||||
for (
|
||||
let idx = 0;
|
||||
idx <
|
||||
Math.min(
|
||||
candidateSegments.length,
|
||||
filePathSegments.length,
|
||||
);
|
||||
idx++
|
||||
) {
|
||||
if (
|
||||
candidateSegments[idx] ===
|
||||
filePathSegments[idx]
|
||||
) {
|
||||
score++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestCandidate = [key, data];
|
||||
} else if (
|
||||
score === bestScore &&
|
||||
bestCandidate !== null
|
||||
) {
|
||||
if (
|
||||
filePathDir === "" &&
|
||||
settings.baseDir
|
||||
) {
|
||||
// When the current file is in the base directory, compare candidates by relative depth.
|
||||
const basePrefix =
|
||||
settings.baseDir + "/";
|
||||
const getRelativeDepth = (
|
||||
k: string,
|
||||
): number => {
|
||||
if (k.startsWith(basePrefix)) {
|
||||
// Remove the baseDir part and count the remaining segments (excluding the filename)
|
||||
const relativeParts = k
|
||||
.slice(basePrefix.length)
|
||||
.split("/");
|
||||
return relativeParts.length - 1;
|
||||
}
|
||||
return Infinity;
|
||||
};
|
||||
|
||||
const candidateDepth =
|
||||
getRelativeDepth(key);
|
||||
const bestCandidateDepth =
|
||||
getRelativeDepth(bestCandidate[0]);
|
||||
|
||||
// Prefer the candidate with fewer directory segments (i.e., lower depth).
|
||||
if (
|
||||
candidateDepth <
|
||||
bestCandidateDepth ||
|
||||
(candidateDepth ===
|
||||
bestCandidateDepth &&
|
||||
key.length <
|
||||
bestCandidate[0].length)
|
||||
) {
|
||||
bestCandidate = [key, data];
|
||||
}
|
||||
} else {
|
||||
// Otherwise, choose the candidate with fewer directory segments.
|
||||
const currentBestDir =
|
||||
bestCandidate[0].slice(
|
||||
0,
|
||||
bestCandidate[0].lastIndexOf(
|
||||
"/",
|
||||
),
|
||||
);
|
||||
const currentBestSegments =
|
||||
currentBestDir.split("/");
|
||||
if (
|
||||
candidateSegments.length <
|
||||
currentBestSegments.length ||
|
||||
(candidateSegments.length ===
|
||||
currentBestSegments.length &&
|
||||
key.length <
|
||||
bestCandidate[0].length)
|
||||
) {
|
||||
bestCandidate = [key, data];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestCandidate !== null) {
|
||||
let linkPath = bestCandidate[1].canonical;
|
||||
// Remove pages/ prefix when baseDir is set
|
||||
if (settings.baseDir && linkPath.startsWith("pages/")) {
|
||||
linkPath = linkPath.slice("pages/".length);
|
||||
}
|
||||
|
||||
// Remove base/ prefix when in base directory
|
||||
if (settings.baseDir && linkPath.startsWith(settings.baseDir + "/")) {
|
||||
linkPath = linkPath.slice((settings.baseDir + "/").length);
|
||||
}
|
||||
result += `[[${linkPath}]]`;
|
||||
i += word.length;
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
result += text[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If no rule applies, output the current character.
|
||||
result += text[i];
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Process the entire body while preserving protected segments.
|
||||
let resultBody = "";
|
||||
let lastIndex = 0;
|
||||
for (const m of body.matchAll(protectedRegex)) {
|
||||
const mIndex = m.index ?? 0;
|
||||
const segment = body.slice(lastIndex, mIndex);
|
||||
resultBody += replaceInSegment(segment);
|
||||
// Append the protected segment unchanged.
|
||||
resultBody += m[0];
|
||||
lastIndex = mIndex + m[0].length;
|
||||
}
|
||||
resultBody += replaceInSegment(body.slice(lastIndex));
|
||||
|
||||
return resultBody;
|
||||
};
|
||||
16
src/replace-links/types.ts
Normal file
16
src/replace-links/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { CandidateData, TrieNode } from "../trie";
|
||||
|
||||
export interface ReplaceLinksOptions {
|
||||
body: string;
|
||||
linkResolverContext: {
|
||||
filePath: string;
|
||||
trie: TrieNode;
|
||||
candidateMap: Map<string, CandidateData>;
|
||||
};
|
||||
settings?: {
|
||||
minCharCount?: number;
|
||||
namespaceResolution?: boolean;
|
||||
baseDir?: string;
|
||||
ignoreDateFormats?: boolean;
|
||||
};
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ export interface CandidateData {
|
|||
|
||||
export const buildCandidateTrie = (
|
||||
allFiles: PathAndAliases[],
|
||||
baseDir = "pages",
|
||||
baseDir?: string,
|
||||
) => {
|
||||
// Process candidate strings from file paths.
|
||||
type Candidate = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue