Merge pull request #2 from kdnk/refactor-tests

refactor: restructure replace-links module and improve namespace reso…
This commit is contained in:
Kodai Nakamura 2025-02-16 12:58:27 +09:00 committed by GitHub
commit df44dd79ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2919 additions and 1633 deletions

View file

@ -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": "",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

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

View 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("タグ");
});
});
});

View file

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

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

View file

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

View 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),
}));
};

View file

@ -0,0 +1,2 @@
export { getEffectiveNamespace, replaceLinks } from "./replace-links";
export type { ReplaceLinksOptions } from "./types";

View 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;
};

View 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;
};
}

View file

@ -67,7 +67,7 @@ export interface CandidateData {
export const buildCandidateTrie = (
allFiles: PathAndAliases[],
baseDir = "pages",
baseDir?: string,
) => {
// Process candidate strings from file paths.
type Candidate = {