chore: add baseDirs option to replaceLinks

This commit is contained in:
Kodai Nakamura 2025-02-09 15:20:52 +09:00
parent e9452f6711
commit ee70bf5617
2 changed files with 206 additions and 114 deletions

View file

@ -8,6 +8,7 @@ export const replaceLinks = async ({
settings = {
minCharCount: 0,
namespaceResolution: true,
baseDirs: undefined,
},
}: {
body: string;
@ -20,6 +21,7 @@ export const replaceLinks = async ({
settings?: {
minCharCount?: number;
namespaceResolution?: boolean;
baseDirs?: string[];
};
}): Promise<string> => {
// Return content as-is if it's shorter than the minimum character count.
@ -53,12 +55,34 @@ export const replaceLinks = async ({
return frontmatter + body;
}
// Helper to get the current file's toplevel namespace.
const getCurrentNamespace = (filePath: string): string => {
/**
* Returns the effective namespace for the given file path.
* If the filePath starts with one of the baseDirs (e.g. "pages/"), then the directory
* immediately under the baseDir is considered the effective namespace.
*/
const getEffectiveNamespace = (
filePath: string,
baseDirs: string[],
): string => {
for (const baseDir of baseDirs) {
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.length > 1 ? segments[0] : "";
return segments[0] || "";
};
const currentNamespace = getCurrentNamespace(filePath);
// Compute current file's effective namespace using settings.baseDirs if provided.
const currentNamespace = settings.baseDirs
? getEffectiveNamespace(filePath, settings.baseDirs)
: (function () {
const segments = filePath.split("/");
return segments[0] || "";
})();
// Helper function to process a plain text segment.
const replaceInSegment = (text: string): string => {
@ -108,7 +132,7 @@ export const replaceLinks = async ({
candidate,
);
const isKorean = /^[\p{Script=Hangul}]+$/u.test(candidate);
// For nonCJK or Korean candidates, perform word boundary checks.
// For non-CJK or Korean candidates, perform word boundary checks.
if (!isCjkCandidate || isKorean) {
if (isKorean) {
// Check for common Korean particle suffix (e.g. "이다" optionally with a period).
@ -133,9 +157,9 @@ export const replaceLinks = async ({
continue outer;
}
}
// If candidate is nonKorean CJK, no boundary check is applied.
// For non-Korean CJK candidates, no boundary check is applied.
// Additionally, if namespace resolution is enabled and the candidates file is restricted,
// only replace if the current file's toplevel namespace matches.
// only replace if the current file's effective namespace matches.
if (
settings.namespaceResolution &&
candidateData.restrictNamespace &&
@ -1296,6 +1320,7 @@ if (import.meta.vitest) {
namespace: "",
},
],
// Candidate without namespace restriction.
[
"free",
{
@ -1304,7 +1329,8 @@ if (import.meta.vitest) {
namespace: "other",
},
],
// alias 用
// For alias testing:
// Assume file "pages/HelloWorld" with shorthand "HelloWorld"
[
"pages/HelloWorld",
{
@ -1313,6 +1339,7 @@ if (import.meta.vitest) {
namespace: "pages",
},
],
// Alias "Hello" is different from the shorthand, so canonical becomes "pages/HelloWorld|Hello".
[
"Hello",
{
@ -1321,6 +1348,7 @@ if (import.meta.vitest) {
namespace: "pages",
},
],
// Also register the shorthand candidate.
[
"HelloWorld",
{
@ -1329,6 +1357,7 @@ if (import.meta.vitest) {
namespace: "pages",
},
],
// For "tags" test: candidate key "tags" should map to canonical "tags"
[
"pages/tags",
{
@ -1345,7 +1374,7 @@ if (import.meta.vitest) {
namespace: "pages",
},
],
// 韓国語
// For Korean test, add candidate "문서"
[
"문서",
{
@ -1354,7 +1383,7 @@ if (import.meta.vitest) {
namespace: "namespace",
},
],
// 日本語
// For Japanese test, add candidate "ひらがな"
[
"ひらがな",
{
@ -1363,7 +1392,7 @@ if (import.meta.vitest) {
namespace: "namespace",
},
],
// 中国語
// For Chinese test, add candidate "文档"
[
"文档",
{
@ -1373,114 +1402,164 @@ if (import.meta.vitest) {
},
],
]);
const trie = buildTrie(Array.from(candidateMap.keys()));
describe("replaceLinks", () => {
it("CJK - Korean > converts Korean words within sentence", async () => {
const body = "이 문서는 문서이다.";
it("CJK - Korean > converts Korean words within sentence", async () => {
const trie = buildTrie(Array.from(candidateMap.keys()));
const body = "이 문서는 문서이다.";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "namespace/note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("이 문서는 [[문서]]이다.");
});
it("starting CJK > multiple same CJK words", async () => {
const trie = buildTrie(Array.from(candidateMap.keys()));
const body = "- ひらがなとひらがな";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "namespace/note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("- [[ひらがな]]と[[ひらがな]]");
});
it("CJK - Chinese > converts Chinese words within sentence", async () => {
const trie = buildTrie(Array.from(candidateMap.keys()));
const body = "这个文档很好。";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "namespace/note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("这个[[文档]]很好。");
});
it("base character (pages) > unmatched namespace", async () => {
const trie = buildTrie(Array.from(candidateMap.keys()));
const body = "tags";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "root-note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("[[tags]]");
});
it("aliases > replaces alias with canonical form using file path and alias", async () => {
const trie = buildTrie(Array.from(candidateMap.keys()));
const body = "HelloWorld";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "pages/Note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("[[HelloWorld]]");
});
it("aliases > replaces multiple occurrences of alias and normal candidate", async () => {
const trie = buildTrie(Array.from(candidateMap.keys()));
const body = "Hello HelloWorld";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "pages/Note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("[[pages/HelloWorld|Hello]] [[HelloWorld]]");
});
it("replaceLinks > should not replace when inside a protected segment", async () => {
const trie = buildTrie(Array.from(candidateMap.keys()));
const body = "Some text `x` more text";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "namespace/note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("Some text `x` more text");
});
describe("automatic-linker-restrict-namespace and base dir", () => {
// Add candidate "a" corresponding to a file at "pages/set/a"
// with restrictNamespace enabled and an effective namespace of "set".
candidateMap.set("a", {
canonical: "set/a",
restrictNamespace: true,
namespace: "set",
});
const trie = buildTrie(Array.from(candidateMap.keys()));
it("should replace candidate with restrictNamespace when effective namespace matches", async () => {
// Current file is in "pages/set/...", so effective namespace is "set"
const body = "a";
const frontmatter = "";
const filePath = "pages/set/current";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "namespace/note",
trie,
candidateMap,
frontmatter,
linkResolverContext: { filePath, trie, candidateMap },
settings: {
minCharCount: 0,
namespaceResolution: true,
baseDirs: ["pages"],
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("이 문서는 [[문서]]이다.");
expect(result).toBe("[[set/a]]");
});
it("starting CJK > multiple same CJK words", async () => {
const body = "- ひらがなとひらがな";
it("should not replace candidate with restrictNamespace when effective namespace does not match", async () => {
// Current file is in "pages/other/...", so effective namespace is "other"
const body = "a";
const frontmatter = "";
const filePath = "pages/other/current";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "namespace/note",
trie,
candidateMap,
frontmatter,
linkResolverContext: { filePath, trie, candidateMap },
settings: {
minCharCount: 0,
namespaceResolution: true,
baseDirs: ["pages"],
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("- [[ひらがな]]と[[ひらがな]]");
});
it("CJK - Chinese > converts Chinese words within sentence", async () => {
const body = "这个文档很好。";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "namespace/note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("这个[[文档]]很好。");
});
it("base character (pages) > unmatched namespace", async () => {
const body = "tags";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "root-note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("[[tags]]");
});
it("aliases > replaces alias with canonical form using file path and alias", async () => {
const body = "HelloWorld";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "pages/Note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("[[HelloWorld]]");
});
it("aliases > replaces multiple occurrences of alias and normal candidate", async () => {
const body = "Hello HelloWorld";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "pages/Note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe(
"[[pages/HelloWorld|Hello]] [[HelloWorld]]",
);
});
it("replaceLinks > should not replace when inside a protected segment", async () => {
const body = "Some text `x` more text";
const result = await replaceLinks({
frontmatter: "",
body,
linkResolverContext: {
filePath: "namespace/note",
trie,
candidateMap,
},
settings: { minCharCount: 0, namespaceResolution: true },
});
expect(result).toBe("Some text `x` more text");
// Since effective namespace does not match ("set" vs "other"), no replacement occurs.
expect(result).toBe("a");
});
});
});

View file

@ -21,11 +21,23 @@ export interface TrieNode {
candidate?: string;
}
// Helper function to get the toplevel namespace of a path.
// If the file path contains at least one "/" then return the first segment; otherwise return an empty string.
const getNamespace = (path: string): string => {
/**
* Returns the effective namespace for a given file path.
* If the path starts with one of the baseDirs (e.g. "pages/"), the directory immediately
* under the baseDir is considered the effective namespace.
*/
const getEffectiveNamespace = (path: string, baseDirs: string[]): string => {
for (const baseDir of baseDirs) {
const prefix = baseDir + "/";
if (path.startsWith(prefix)) {
const rest = path.slice(prefix.length);
const segments = rest.split("/");
return segments[0] || "";
}
}
// Fallback: return the first segment
const segments = path.split("/");
return segments.length > 1 ? segments[0] : "";
return segments[0] || "";
};
/** 単語リストから Trie を構築 */
@ -60,6 +72,7 @@ export const buildCandidateTrie = (
full: string;
short: string | null;
restrictNamespace: boolean;
// Effective namespace computed relative to baseDirs.
namespace: string;
};
const candidates: Candidate[] = allFiles.map((f) => {
@ -67,7 +80,7 @@ export const buildCandidateTrie = (
full: f.path,
short: null,
restrictNamespace: f.restrictNamespace,
namespace: getNamespace(f.path),
namespace: getEffectiveNamespace(f.path, baseDirs),
};
for (const dir of baseDirs) {
const prefix = `${dir}/`;
@ -120,7 +133,7 @@ export const buildCandidateTrie = (
candidateMap.set(alias, {
canonical: canonicalForAlias,
restrictNamespace: file.restrictNamespace,
namespace: getNamespace(file.path),
namespace: getEffectiveNamespace(file.path, baseDirs),
});
}
}