test: add vitest harness with pure function extraction for link builders

This commit is contained in:
Andre Light 2026-04-13 17:07:00 -06:00
parent 7be3eee3b3
commit ce1b439704
6 changed files with 2120 additions and 244 deletions

1266
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,9 @@
"release": "node scripts/release-tag.mjs",
"bump": "node scripts/bump.mjs",
"lint": "eslint src/ --ext .ts",
"lint:fix": "eslint src/ --ext .ts --fix"
"lint:fix": "eslint src/ --ext .ts --fix",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [],
"author": "",
@ -28,7 +30,8 @@
"eslint-plugin-obsidianmd": "^0.1.9",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.4"
},
"dependencies": {
"dotenv": "^16.6.1"

704
src/linkBuilder.test.ts Normal file
View file

@ -0,0 +1,704 @@
import { describe, it, expect } from 'vitest';
import { buildHeadingLink, buildBlockLink, buildFileLink, extractBlockDisplayText, encodeMarkdownLinkUrl } from './linkBuilder';
import { LinkFormat } from './type';
// ---------------------------------------------------------------------------
// encodeMarkdownLinkUrl
// ---------------------------------------------------------------------------
describe('encodeMarkdownLinkUrl', () => {
it('encodes spaces as %20', () => {
expect(encodeMarkdownLinkUrl('My Note')).toBe('My%20Note');
});
it('encodes multiple spaces', () => {
expect(encodeMarkdownLinkUrl('a b c')).toBe('a%20b%20c');
});
it('returns string unchanged when no spaces', () => {
expect(encodeMarkdownLinkUrl('MyNote#Heading')).toBe('MyNote#Heading');
});
it('does not encode other special characters', () => {
expect(encodeMarkdownLinkUrl('Note#Head^id')).toBe('Note#Head^id');
});
});
// ---------------------------------------------------------------------------
// buildHeadingLink
// ---------------------------------------------------------------------------
describe('buildHeadingLink', () => {
const defaults = {
linkFormat: LinkFormat.WIKILINK,
useHeadingAsDisplayText: true,
headingLinkSeparator: '#',
};
// -- Wiki format ---------------------------------------------------------
describe('wiki format', () => {
it('creates a basic heading link with display text', () => {
const result = buildHeadingLink({
...defaults,
heading: 'Getting Started',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#Getting Started|Getting Started]]');
expect(result.isNoteLink).toBe(false);
});
it('simplifies to [[filename]] when heading equals filename exactly', () => {
const result = buildHeadingLink({
...defaults,
heading: 'MyNote',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote]]');
expect(result.isNoteLink).toBe(true);
});
it('simplifies linkContent when heading matches filename case-insensitively', () => {
const result = buildHeadingLink({
...defaults,
heading: 'mynote',
filename: 'MyNote',
});
// linkContent simplified to filename, but since filename !== heading,
// it falls to the else branch with displayText vs linkContent
expect(result.link).toBe('[[MyNote|mynote]]');
expect(result.isNoteLink).toBe(true);
});
it('simplifies when heading matches filename with spaces removed', () => {
// e.g. filename "SomeThing" heading "Some Thing"
const result = buildHeadingLink({
...defaults,
heading: 'Some Thing',
filename: 'SomeThing',
});
expect(result.link).toBe('[[SomeThing|Some Thing]]');
expect(result.isNoteLink).toBe(true);
});
it('omits display text alias when displayText equals linkContent', () => {
// When useHeadingAsDisplayText is false AND separator is '#',
// displayText = "filename#heading" which equals linkContent
const result = buildHeadingLink({
...defaults,
heading: 'Intro',
filename: 'MyNote',
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
expect(result.link).toBe('[[MyNote#Intro]]');
});
it('includes alias when using non-default separator', () => {
const result = buildHeadingLink({
...defaults,
heading: 'Intro',
filename: 'MyNote',
useHeadingAsDisplayText: false,
headingLinkSeparator: ' > ',
});
expect(result.link).toBe('[[MyNote#Intro|MyNote > Intro]]');
});
it('uses frontmatter title in display text when heading-as-display is off', () => {
const result = buildHeadingLink({
...defaults,
heading: 'Setup',
filename: 'my-note',
frontmatterTitle: 'My Note',
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
// displayText = "My Note#Setup", linkContent = "my-note#Setup"
expect(result.link).toBe('[[my-note#Setup|My Note#Setup]]');
});
it('strips [[ ]] wrapper from heading', () => {
const result = buildHeadingLink({
...defaults,
heading: '[[Inner Link]]',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#Inner Link|Inner Link]]');
});
it('falls back separator to # when headingLinkSeparator is empty', () => {
const result = buildHeadingLink({
...defaults,
heading: 'Intro',
filename: 'MyNote',
useHeadingAsDisplayText: false,
headingLinkSeparator: '',
});
expect(result.link).toBe('[[MyNote#Intro]]');
});
});
// -- Markdown format ------------------------------------------------------
describe('markdown format', () => {
const md = { ...defaults, linkFormat: LinkFormat.MDLINK };
it('creates a basic markdown heading link', () => {
const result = buildHeadingLink({
...md,
heading: 'Getting Started',
filename: 'MyNote',
});
expect(result.link).toBe('[Getting Started](MyNote#Getting%20Started)');
});
it('always includes #heading even when filename matches heading', () => {
const result = buildHeadingLink({
...md,
heading: 'MyNote',
filename: 'MyNote',
});
// Markdown format always uses filename#heading; no spaces so no encoding
expect(result.link).toBe('[MyNote](MyNote#MyNote)');
expect(result.isNoteLink).toBe(true);
});
it('uses filename+separator+heading when heading-as-display is off', () => {
const result = buildHeadingLink({
...md,
heading: 'Intro',
filename: 'MyNote',
useHeadingAsDisplayText: false,
headingLinkSeparator: ' - ',
});
// Display text is not encoded, only the URL portion is
expect(result.link).toBe('[MyNote - Intro](MyNote#Intro)');
});
it('uses frontmatter title in display text', () => {
const result = buildHeadingLink({
...md,
heading: 'Setup',
filename: 'my-note',
frontmatterTitle: 'My Note',
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
// No spaces in filename or heading, so no encoding needed
expect(result.link).toBe('[My Note#Setup](my-note#Setup)');
});
});
// -- compareIgnoreCase `includes` behavior --------------------------------
// BUG: compareIgnoreCase uses filename.includes(heading), which causes
// false-positive note-link simplification when the filename merely contains
// the heading as a substring. Should be fixed in a future PR (invert these
// tests when fixing).
describe('filename-heading substring matching (known bug)', () => {
it('false-positive: simplifies when filename contains heading as substring', () => {
// BUG: "JavaScript".includes("Java") → true, incorrectly simplifies.
// Expected correct behavior: should NOT simplify (not an exact or
// derived-name match).
const result = buildHeadingLink({
...defaults,
heading: 'Java',
filename: 'JavaScript',
});
expect(result.link).toBe('[[JavaScript|Java]]');
expect(result.isNoteLink).toBe(true);
});
it('does NOT simplify when heading contains filename as substring', () => {
// The includes check is directional: filename.includes(heading)
// "Note".includes("Notebook") → false
const result = buildHeadingLink({
...defaults,
heading: 'Notebook',
filename: 'Note',
});
expect(result.link).toBe('[[Note#Notebook|Notebook]]');
expect(result.isNoteLink).toBe(false);
});
it('false-positive: simplifies on space-removed substring match', () => {
// BUG: "SomeThingElse".includes("SomeThing") → true, incorrectly
// simplifies. The intended behavior (SomeThing/Some Thing) works,
// but non-exact substring matches like this are false positives.
const result = buildHeadingLink({
...defaults,
heading: 'Some Thing',
filename: 'SomeThingElse',
});
expect(result.link).toBe('[[SomeThingElse|Some Thing]]');
expect(result.isNoteLink).toBe(true);
});
});
// -- Combinatorial --------------------------------------------------------
describe('combinatorial', () => {
it('frontmatterTitle has no effect when useHeadingAsDisplayText is true', () => {
const result = buildHeadingLink({
...defaults,
heading: 'Setup',
filename: 'my-note',
frontmatterTitle: 'My Custom Title',
useHeadingAsDisplayText: true,
});
// Display text is the heading, not the frontmatter title
expect(result.link).toBe('[[my-note#Setup|Setup]]');
});
it('frontmatterTitle + note-link simplification (wiki)', () => {
const result = buildHeadingLink({
...defaults,
heading: 'myNote',
filename: 'MyNote',
frontmatterTitle: 'My Pretty Title',
useHeadingAsDisplayText: false,
headingLinkSeparator: '#',
});
// linkContent simplified to "MyNote", display = "My Pretty Title#myNote"
expect(result.link).toBe('[[MyNote|My Pretty Title#myNote]]');
expect(result.isNoteLink).toBe(true);
});
it('empty heading triggers false note-link simplification (known bug)', () => {
// BUG: ''.includes('') is always true in JS, so compareIgnoreCase
// falsely matches any filename. Should produce a heading link with
// an empty fragment instead. Fix alongside compareIgnoreCase in a
// future PR.
const result = buildHeadingLink({
...defaults,
heading: '',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote|]]');
expect(result.isNoteLink).toBe(true);
});
it('markdown format URL-encodes spaces in link URL', () => {
const result = buildHeadingLink({
...defaults,
linkFormat: LinkFormat.MDLINK,
heading: 'My Heading',
filename: 'My Note',
});
expect(result.link).toBe('[My Heading](My%20Note#My%20Heading)');
});
});
});
// ---------------------------------------------------------------------------
// extractBlockDisplayText
// ---------------------------------------------------------------------------
describe('extractBlockDisplayText', () => {
describe('text cleaning', () => {
it('removes block ID suffix (^...)', () => {
const result = extractBlockDisplayText('Some text ^abc123', 'fallback', 3, 5);
expect(result).toBe('Some text');
});
it('removes checkbox prefix (- [x] )', () => {
const result = extractBlockDisplayText('- [x] Task done', 'fallback', 3, 5);
expect(result).toBe('Task done');
});
it('removes list prefix (- )', () => {
const result = extractBlockDisplayText('- List item here today', 'fallback', 3, 5);
expect(result).toBe('List item here');
});
it('removes markdown syntax characters', () => {
const result = extractBlockDisplayText('**bold** and `code`', 'fallback', 5, 5);
expect(result).toBe('bold and code');
});
it('returns blockId for empty/whitespace-only text', () => {
expect(extractBlockDisplayText('', 'myId', 3, 5)).toBe('myId');
expect(extractBlockDisplayText(' ', 'myId', 3, 5)).toBe('myId');
});
it('removes callout prefix (> )', () => {
const result = extractBlockDisplayText('> Some quoted text here', 'fallback', 3, 5);
expect(result).toBe('Some quoted text');
});
});
describe('English text', () => {
it('extracts first 3 words by default', () => {
const result = extractBlockDisplayText('The quick brown fox jumps', 'fallback', 3, 5);
expect(result).toBe('The quick brown');
});
it('respects custom word limit', () => {
const result = extractBlockDisplayText('The quick brown fox jumps', 'fallback', 5, 5);
expect(result).toBe('The quick brown fox jumps');
});
it('handles text with fewer words than limit', () => {
const result = extractBlockDisplayText('Hello world', 'fallback', 3, 5);
expect(result).toBe('Hello world');
});
it('handles single word', () => {
const result = extractBlockDisplayText('Hello', 'fallback', 3, 5);
expect(result).toBe('Hello');
});
it('defaults word limit to 3 if zero is passed', () => {
const result = extractBlockDisplayText('one two three four', 'fallback', 0, 5);
expect(result).toBe('one two three');
});
});
describe('CJK text', () => {
it('returns text as-is when within char limit', () => {
const result = extractBlockDisplayText('Hello', 'fallback', 3, 5);
// "Hello" is ASCII / English, so it hits the English branch
expect(result).toBe('Hello');
});
it('truncates with ellipsis when text exceeds char limit and no separator found', () => {
const result = extractBlockDisplayText('这是一段很长的中文文本没有标点', 'fallback', 3, 5);
expect(result).toBe('这是一段很...');
});
it('uses punctuation as separator when match is 3+ chars', () => {
// '三个字' is 3 chars (>= 3 and <= charLimit), so separator match is used
const result = extractBlockDisplayText('三个字,后面还有很多内容', 'fallback', 3, 5);
expect(result).toBe('三个字');
});
it('falls back to char limit slice when separator match is too short (< 3 chars)', () => {
// '短语' is 2 chars (< 3), falls back to text.slice(0, charLimit)
const result = extractBlockDisplayText('短语,后面还有很多内容', 'fallback', 3, 5);
expect(result).toBe('短语,后面');
});
it('returns full text when within char limit', () => {
const result = extractBlockDisplayText('短い文', 'fallback', 3, 5);
expect(result).toBe('短い文');
});
it('defaults char limit to 5 if zero is passed', () => {
const result = extractBlockDisplayText('这是一段很长的中文文本', 'fallback', 3, 0);
expect(result).toBe('这是一段很...');
});
});
// -- Boundary values ------------------------------------------------------
describe('boundary values', () => {
it('English: exactly wordLimit words returns all words', () => {
const result = extractBlockDisplayText('one two three', 'fallback', 3, 5);
expect(result).toBe('one two three');
});
it('English: wordLimit + 1 words truncates', () => {
const result = extractBlockDisplayText('one two three four', 'fallback', 3, 5);
expect(result).toBe('one two three');
});
it('English: word limit of 1', () => {
const result = extractBlockDisplayText('Hello world', 'fallback', 1, 5);
expect(result).toBe('Hello');
});
it('CJK: exactly charLimit chars returns text as-is', () => {
const result = extractBlockDisplayText('这是一段文', 'fallback', 3, 5);
expect(result).toBe('这是一段文');
});
it('CJK: charLimit + 1 chars with no separator triggers truncation', () => {
const result = extractBlockDisplayText('这是一段文本', 'fallback', 3, 5);
expect(result).toBe('这是一段文...');
});
it('CJK: char limit of 1 truncates with ellipsis', () => {
const result = extractBlockDisplayText('很长的文本', 'fallback', 3, 1);
expect(result).toBe('很...');
});
});
// -- Mixed content --------------------------------------------------------
describe('mixed content', () => {
it('mixed English/CJK is treated as non-English', () => {
// The ASCII regex fails if any non-ASCII char is present
const result = extractBlockDisplayText('Hello 世界 and more text', 'fallback', 3, 5);
expect(result).toBe('Hello');
});
it('text that is all markdown syntax becomes empty after cleaning', () => {
const result = extractBlockDisplayText('**[]**', 'myId', 3, 5);
expect(result).toBe('myId');
});
it('checkbox with different state markers', () => {
expect(extractBlockDisplayText('- [>] Deferred task here', 'id', 3, 5))
.toBe('Deferred task here');
expect(extractBlockDisplayText('- [ ] Unchecked task here', 'id', 3, 5))
.toBe('Unchecked task here');
});
});
});
// ---------------------------------------------------------------------------
// buildBlockLink
// ---------------------------------------------------------------------------
describe('buildBlockLink', () => {
const defaults = {
filename: 'MyNote',
useBrief: true,
firstLine: 'The quick brown fox',
linkFormat: LinkFormat.WIKILINK,
autoBlockDisplayText: true,
autoEmbedBlockLink: false,
blockDisplayWordLimit: 3,
blockDisplayCharLimit: 5,
};
describe('wiki format', () => {
it('creates a block link with brief display text', () => {
const result = buildBlockLink({ ...defaults, blockId: 'abc123' });
expect(result).toBe('[[MyNote#^abc123|The quick brown]]');
});
it('uses blockId as display text when useBrief is false', () => {
const result = buildBlockLink({ ...defaults, blockId: 'abc123', useBrief: false });
expect(result).toBe('[[MyNote#^abc123|abc123]]');
});
it('omits display text when autoBlockDisplayText is false', () => {
const result = buildBlockLink({
...defaults,
blockId: 'abc123',
autoBlockDisplayText: false,
});
expect(result).toBe('[[MyNote#^abc123]]');
});
it('adds ! embed prefix', () => {
const result = buildBlockLink({
...defaults,
blockId: 'abc123',
autoEmbedBlockLink: true,
});
expect(result).toBe('![[MyNote#^abc123|The quick brown]]');
});
});
describe('markdown format', () => {
const md = { ...defaults, linkFormat: LinkFormat.MDLINK };
it('creates a markdown block link with display text', () => {
const result = buildBlockLink({ ...md, blockId: 'abc123' });
expect(result).toBe('[The quick brown](MyNote#^abc123)');
});
it('omits display text when autoBlockDisplayText is false', () => {
const result = buildBlockLink({
...md,
blockId: 'abc123',
autoBlockDisplayText: false,
});
expect(result).toBe('[](MyNote#^abc123)');
});
it('adds ! embed prefix in markdown format', () => {
const result = buildBlockLink({
...md,
blockId: 'abc123',
autoEmbedBlockLink: true,
});
expect(result).toBe('![The quick brown](MyNote#^abc123)');
});
});
it('uses blockId as display when firstLine is empty', () => {
const result = buildBlockLink({
...defaults,
blockId: 'abc123',
firstLine: '',
});
expect(result).toBe('[[MyNote#^abc123|abc123]]');
});
// -- Combinatorial --------------------------------------------------------
describe('combinatorial', () => {
it('embed + no display text (wiki) produces ![[...]]', () => {
const result = buildBlockLink({
...defaults,
blockId: 'abc123',
autoBlockDisplayText: false,
autoEmbedBlockLink: true,
});
expect(result).toBe('![[MyNote#^abc123]]');
});
it('embed + no display text (markdown) produces image-like syntax', () => {
// ![](path) is technically an image embed in standard markdown —
// documenting that this combo produces that syntax
const result = buildBlockLink({
...defaults,
linkFormat: LinkFormat.MDLINK,
blockId: 'abc123',
autoBlockDisplayText: false,
autoEmbedBlockLink: true,
});
expect(result).toBe('![](MyNote#^abc123)');
});
it('block ID with hyphens and underscores', () => {
const result = buildBlockLink({
...defaults,
blockId: 'quote-of_the-day',
});
expect(result).toBe('[[MyNote#^quote-of_the-day|The quick brown]]');
});
it('markdown format encodes spaces in filename', () => {
const result = buildBlockLink({
...defaults,
linkFormat: LinkFormat.MDLINK,
blockId: 'abc123',
filename: 'My Note',
});
expect(result).toBe('[The quick brown](My%20Note#^abc123)');
});
it('wiki format does NOT encode spaces in filename', () => {
const result = buildBlockLink({
...defaults,
blockId: 'abc123',
filename: 'My Note',
});
expect(result).toBe('[[My Note#^abc123|The quick brown]]');
});
});
});
// ---------------------------------------------------------------------------
// buildFileLink
// ---------------------------------------------------------------------------
describe('buildFileLink', () => {
describe('wiki format', () => {
it('creates a wiki file link', () => {
const result = buildFileLink({
filename: 'MyNote',
filePath: 'folder/MyNote.md',
linkFormat: LinkFormat.WIKILINK,
});
expect(result).toBe('[[MyNote|MyNote]]');
});
it('uses custom display text', () => {
const result = buildFileLink({
filename: 'my-note',
filePath: 'folder/my-note.md',
displayText: 'My Note',
linkFormat: LinkFormat.WIKILINK,
});
expect(result).toBe('[[my-note|My Note]]');
});
});
describe('markdown format', () => {
it('creates a markdown file link and strips .md', () => {
const result = buildFileLink({
filename: 'MyNote',
filePath: 'folder/MyNote.md',
linkFormat: LinkFormat.MDLINK,
});
expect(result).toBe('[MyNote](folder/MyNote)');
});
it('normalizes backslashes in path', () => {
const result = buildFileLink({
filename: 'MyNote',
filePath: 'folder\\subfolder\\MyNote.md',
linkFormat: LinkFormat.MDLINK,
});
expect(result).toBe('[MyNote](folder/subfolder/MyNote)');
});
it('uses custom display text', () => {
const result = buildFileLink({
filename: 'my-note',
filePath: 'folder/my-note.md',
displayText: 'My Note',
linkFormat: LinkFormat.MDLINK,
});
expect(result).toBe('[My Note](folder/my-note)');
});
it('handles paths without .md extension', () => {
const result = buildFileLink({
filename: 'MyNote',
filePath: 'folder/MyNote',
linkFormat: LinkFormat.MDLINK,
});
expect(result).toBe('[MyNote](folder/MyNote)');
});
it('encodes spaces as %20 in path', () => {
const result = buildFileLink({
filename: 'My Note',
filePath: 'my folder/My Note.md',
linkFormat: LinkFormat.MDLINK,
});
expect(result).toBe('[My Note](my%20folder/My%20Note)');
});
it('root-level file with no directory prefix', () => {
const result = buildFileLink({
filename: 'MyNote',
filePath: 'MyNote.md',
linkFormat: LinkFormat.MDLINK,
});
expect(result).toBe('[MyNote](MyNote)');
});
});
// -- Edge cases -----------------------------------------------------------
describe('edge cases', () => {
it('wiki format without displayText produces redundant alias', () => {
// [[name|name]] — the alias duplicates the filename
const result = buildFileLink({
filename: 'MyNote',
filePath: 'folder/MyNote.md',
linkFormat: LinkFormat.WIKILINK,
});
expect(result).toBe('[[MyNote|MyNote]]');
});
it('filename with parentheses in markdown format', () => {
// Parentheses can break [text](path) syntax in strict parsers
const result = buildFileLink({
filename: 'Note (draft)',
filePath: 'folder/Note (draft).md',
linkFormat: LinkFormat.MDLINK,
});
expect(result).toBe('[Note (draft)](folder/Note%20(draft))');
});
it('filename with pipe in wiki format', () => {
// | is the alias separator — pipe in filename creates ambiguity
const result = buildFileLink({
filename: 'Pros | Cons',
filePath: 'Pros | Cons.md',
displayText: 'Comparison',
linkFormat: LinkFormat.WIKILINK,
});
expect(result).toBe('[[Pros | Cons|Comparison]]');
});
});
});

195
src/linkBuilder.ts Normal file
View file

@ -0,0 +1,195 @@
import { LinkFormat } from './type';
// --- URL Encoding ---
export function encodeMarkdownLinkUrl(url: string): string {
return url.replace(/ /g, '%20');
}
// --- Heading Link ---
export interface BuildHeadingLinkOptions {
heading: string;
filename: string;
frontmatterTitle?: string;
linkFormat: LinkFormat;
useHeadingAsDisplayText: boolean;
headingLinkSeparator: string;
}
export interface BuildHeadingLinkResult {
link: string;
isNoteLink: boolean;
}
export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeadingLinkResult {
const { filename, linkFormat, useHeadingAsDisplayText, headingLinkSeparator } = options;
const filenameOrTitle = options.frontmatterTitle || filename;
// 提取标题文本和级别
// 如果内容是[[内容]],移除[[]]
let selectedHeading = options.heading;
if (selectedHeading.startsWith('[[') && selectedHeading.endsWith(']]')) {
selectedHeading = selectedHeading.slice(2, -2);
}
// 根据设置决定显示文本
let displayText = selectedHeading;
if (!useHeadingAsDisplayText) {
// 如果不使用标题作为显示文本,则使用"文件名{连接符}标题名"格式
const separator = headingLinkSeparator || '#';
displayText = `${filenameOrTitle}${separator}${selectedHeading}`;
}
let linkContent = `${filename}#${selectedHeading}`;
let isNoteLink = false;
const compareIgnoreCase = (a: string, b: string): boolean =>
a.toLowerCase() === b.toLowerCase() || a.toLowerCase().includes(b.toLowerCase());
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
if (
filename === selectedHeading ||
compareIgnoreCase(filename, selectedHeading) ||
compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, ''))
) {
linkContent = filename;
isNoteLink = true;
}
let link = '';
// 根据设置选择链接格式
if (linkFormat === LinkFormat.WIKILINK) {
// Wiki链接格式
if (filename === selectedHeading) {
// 特殊情况:当文件名与标题相同时,直接链接到文件
link = `[[${filename}]]`;
isNoteLink = true;
} else {
if (displayText === linkContent) {
// 特殊情况:当显示文本与 "文件名#标题" 相同时,省略显示文本
link = `[[${linkContent}]]`;
} else {
link = `[[${linkContent}|${displayText}]]`;
}
}
} else {
// Markdown链接格式
link = `[${displayText}](${encodeMarkdownLinkUrl(`${filename}#${selectedHeading}`)})`;
}
return { link, isNoteLink };
}
// --- Block Display Text ---
export function extractBlockDisplayText(
firstLine: string,
blockId: string,
wordLimit: number,
charLimit: number,
): string {
let text = firstLine;
// 先去掉结尾的 ^ 及其后面的内容(如果有的话)
text = text.replace(/\^.*\s*$/, '');
text = text.trim().replace(/- \[.\]\s+/, '').replace('- ', '').replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, '');
if (!text) return blockId;
// 判断是否是纯英文,如果是纯英文(以及英文常用标点符号),提取前几个单词;否则,按下面的逻辑处理
// 根据 ASCII 来判断"英文"
const isEnglish = /^[a-zA-Z\s,.!?"()[\]_^-~:;0-9]*$/.test(text);
if (isEnglish) {
const limit = wordLimit || 3;
return text.trim().split(' ').slice(0, limit).join(' ');
}
// CJK / 非英文语言
const limit = charLimit || 5;
if (text.length > limit) {
const separated = text.trim().match(/(\S+?)[~,.\-=[,。?!…:\n\s]/);
const tempText = separated ? separated[1] : text;
if (tempText.length > limit) {
return tempText.slice(0, limit) + '...';
} else if (tempText.length < 3) {
return text.slice(0, limit);
} else {
return tempText;
}
}
return text;
}
// --- Block Link ---
export interface BuildBlockLinkOptions {
blockId: string;
filename: string;
useBrief: boolean;
firstLine: string;
linkFormat: LinkFormat;
autoBlockDisplayText: boolean;
autoEmbedBlockLink: boolean;
blockDisplayWordLimit: number;
blockDisplayCharLimit: number;
}
export function buildBlockLink(options: BuildBlockLinkOptions): string {
const {
blockId, filename, useBrief, firstLine,
linkFormat, autoBlockDisplayText, autoEmbedBlockLink,
blockDisplayWordLimit, blockDisplayCharLimit,
} = options;
let displayText = blockId;
if (useBrief && firstLine) {
displayText = extractBlockDisplayText(firstLine, blockId, blockDisplayWordLimit, blockDisplayCharLimit);
}
let link: string;
if (autoBlockDisplayText) {
link = linkFormat === LinkFormat.WIKILINK
? `[[${filename}#^${blockId}|${displayText}]]`
: `[${displayText}](${encodeMarkdownLinkUrl(filename)}#^${blockId})`; // markdown 格式不能加 ^,不然会变成内联脚注语法 [^xxx]
} else {
link = linkFormat === LinkFormat.WIKILINK
? `[[${filename}#^${blockId}]]`
: `[](${encodeMarkdownLinkUrl(filename)}#^${blockId})`;
}
// 自动生成嵌入块
if (autoEmbedBlockLink) {
link = '!' + link;
}
return link;
}
// --- File Link ---
export interface BuildFileLinkOptions {
filename: string;
filePath: string;
displayText?: string;
linkFormat: LinkFormat;
}
export function buildFileLink(options: BuildFileLinkOptions): string {
const { filename, linkFormat } = options;
const display = options.displayText || filename;
if (linkFormat === LinkFormat.WIKILINK) {
return `[[${filename}|${display}]]`;
}
let path = options.filePath.replace(/\\/g, '/');
if (path.endsWith('.md')) path = path.slice(0, -3);
return `[${display}](${encodeMarkdownLinkUrl(path)})`;
}

View file

@ -1,8 +1,9 @@
import { Editor, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo } from 'obsidian';
import { Language, TranslationKey, I18n } from './i18n';
import { ContextData, ContextType, DEFAULT_SETTINGS, EasyCopySettings, LinkFormat, BlockIdInsertPosition } from './type';
import { ContextData, ContextType, DEFAULT_SETTINGS, EasyCopySettings, BlockIdInsertPosition } from './type';
import { EasyCopySettingTab } from './settingTab';
import { BlockIdInputModal } from './blockIdModal';
import { buildHeadingLink, buildBlockLink, buildFileLink } from './linkBuilder';
export default class EasyCopy extends Plugin {
settings: EasyCopySettings;
@ -518,85 +519,31 @@ export default class EasyCopy extends Plugin {
*
*/
private copyBlockLink(content: string, filename: string, useBrief: boolean, firstLine=''): void {
const blockId = content;
let text = firstLine;
const autoDisplayText = this.settings.autoBlockDisplayText;
// 先去掉结尾的 ^ 及其后面的内容(如果有的话)
text = text.replace(/\^.*\s*$/, '');
text = text.trim().replace(/- \[.\]\s+/, '').replace('- ', '').replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, '');
let displayText = blockId;
if (useBrief && text) {
// 判断是否是纯英文,如果是纯英文(以及英文常用标点符号),提取前三个单词;否则,按下面的逻辑处理
// 根据 ASCII 来判断"英文"
const isEnglish = /^[a-zA-Z\s,.!?"()[\]_^-~:;0-9]*$/.test(text);
if (isEnglish) {
const wordLimit = this.settings.blockDisplayWordLimit || 3;
displayText = text.trim().split(' ').slice(0, wordLimit).join(' ');
} else {
const charLimit = this.settings.blockDisplayCharLimit || 5;
const briefText = text;
if (briefText.length > charLimit) {
const seperatedText = text.trim().match(/(\S+?)[~,.\-=[,。?!…:\n\s]/);
let tempText: string | null = null;
if (seperatedText) {
tempText = seperatedText[1];
} else {
tempText = briefText;
}
if (tempText.length > charLimit) {
displayText = tempText.slice(0, charLimit) + '...';
} else if (tempText.length < 3) {
displayText = briefText.slice(0, charLimit);
} else {
displayText = tempText;
}
} else {
displayText = briefText;
}
}
}
// displayText = "^"+displayText;
let blockIdLink = this.settings.linkFormat === LinkFormat.WIKILINK
? `[[${filename}#^${blockId}|${displayText}]]`
: `[${displayText}](${this.encodeMarkdownLinkUrl(filename)}#^${blockId})`; // markdown 格式不能加,不然会变成内联脚注语法 [^xxx]
if (!autoDisplayText) {
blockIdLink = this.settings.linkFormat === LinkFormat.WIKILINK
? `[[${filename}#^${blockId}]]`
: `[](${this.encodeMarkdownLinkUrl(filename)}#^${blockId})`;
}
// 自动生成嵌入块
if (this.settings.autoEmbedBlockLink) {
blockIdLink = "!" + blockIdLink;
}
const blockIdLink = buildBlockLink({
blockId: content,
filename,
useBrief,
firstLine,
linkFormat: this.settings.linkFormat,
autoBlockDisplayText: this.settings.autoBlockDisplayText,
autoEmbedBlockLink: this.settings.autoEmbedBlockLink,
blockDisplayWordLimit: this.settings.blockDisplayWordLimit,
blockDisplayCharLimit: this.settings.blockDisplayCharLimit,
});
navigator.clipboard.writeText(blockIdLink);
if (this.settings.showNotice) {
new Notice(this.t('block-id-copied') + `\n^${displayText}...`);
new Notice(this.t('block-id-copied'));
}
return;
}
/**
*
*/
private copyHeadingLink(content: string, filename: string): void {
let filenameOrTitle = filename;
let title = '';
// 优先使用 frontmatter 属性作为显示文本
let frontmatterTitle: string | undefined;
if (this.settings.useFrontmatterAsDisplay) {
const file = this.app.workspace.getActiveFile?.() ?? (this.app.workspace.getActiveViewOfType?.(MarkdownView)?.file ?? null);
if (file) {
@ -604,75 +551,27 @@ export default class EasyCopy extends Plugin {
const frontmatter = fileCache?.frontmatter;
const key = this.settings.frontmatterKey || 'title';
if (frontmatter && typeof frontmatter[key] === 'string' && frontmatter[key].trim()) {
title = frontmatter[key].trim();
filenameOrTitle = title;
frontmatterTitle = frontmatter[key].trim();
}
}
}
// 提取标题文本和级别
let selectedHeading = content;
// 如果内容是[[内容]],移除[[]]
if (selectedHeading.startsWith('[[') && selectedHeading.endsWith(']]')) {
selectedHeading = selectedHeading.slice(2, -2);
}
// 根据设置决定显示文本
let displayText = selectedHeading;
if (!this.settings.useHeadingAsDisplayText) {
// 如果不使用标题作为显示文本,则使用"文件名{连接符}标题名"格式
const separator = this.settings.headingLinkSeparator || '#';
displayText = `${filenameOrTitle}${separator}${selectedHeading}`;
}
let headingReferenceLink = "";
let noteFlag = 0;
const { link, isNoteLink } = buildHeadingLink({
heading: content,
filename,
frontmatterTitle,
linkFormat: this.settings.linkFormat,
useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText,
headingLinkSeparator: this.settings.headingLinkSeparator,
});
let linkContent = `${filename}#${selectedHeading}`;
navigator.clipboard.writeText(link);
function compareIgnoreCase(a: string, b: string): boolean {
return a.toLowerCase() === b.toLowerCase() || a.toLowerCase().includes(b.toLowerCase());
}
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
if (filename === selectedHeading || compareIgnoreCase(filename, selectedHeading) || compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, ''))) {
linkContent = filename;
if (isNoteLink) {
new Notice(this.t('note-link-simplified'));
noteFlag = 1;
}
// 根据设置选择链接格式
if (this.settings.linkFormat === LinkFormat.WIKILINK) {
// Wiki链接格式
if (filename === selectedHeading) {
// 特殊情况:当文件名与标题相同时,直接链接到文件
headingReferenceLink = `[[${filename}]]`;
noteFlag = 1;
} else {
if (displayText === linkContent) {
// 特殊情况:当显示文本与 "文件名#标题" 相同时,省略显示文本
headingReferenceLink = `[[${linkContent}]]`;
} else {
headingReferenceLink = `[[${linkContent}|${displayText}]]`;
}
}
} else {
// Markdown链接格式
headingReferenceLink = `[${displayText}](${this.encodeMarkdownLinkUrl(`${filename}#${selectedHeading}`)})`;
}
// 复制到剪贴板
navigator.clipboard.writeText(headingReferenceLink);
// 显示通知
if (this.settings.showNotice) {
if (noteFlag) {
new Notice(this.t('note-link-copied'));
} else {
new Notice(this.t('heading-copied'));
}
new Notice(this.t(isNoteLink ? 'note-link-copied' : 'heading-copied'));
}
}
@ -680,8 +579,8 @@ export default class EasyCopy extends Plugin {
* Wiki/Markdown
*/
private copyCurrentFileLink(): void {
// 新增:优先使用 frontmatter 属性作为显示文本
let displayText: string | undefined = undefined;
// 优先使用 frontmatter 属性作为显示文本
let displayText: string | undefined;
if (this.settings.useFrontmatterAsDisplay) {
const file = this.app.workspace.getActiveFile?.() ?? (this.app.workspace.getActiveViewOfType?.(MarkdownView)?.file ?? null);
if (file) {
@ -693,22 +592,20 @@ export default class EasyCopy extends Plugin {
}
}
}
// 获取当前激活文件
const file = this.app.workspace.getActiveFile?.() ?? (this.app.workspace.getActiveViewOfType?.(MarkdownView)?.file ?? null);
if (!file) {
new Notice(this.t('no-file'));
return;
}
const filename = file.basename;
let link = '';
const display = displayText || filename;
if (this.settings.linkFormat === LinkFormat.WIKILINK) {
link = `[[${filename}|${display}]]`;
} else {
let path = file.path.replace(/\\/g, '/');
if (path.endsWith('.md')) path = path.slice(0, -3);
link = `[${display}](${this.encodeMarkdownLinkUrl(path)})`;
}
const link = buildFileLink({
filename: file.basename,
filePath: file.path,
displayText,
linkFormat: this.settings.linkFormat,
});
navigator.clipboard.writeText(link);
if (this.settings.showNotice) {
new Notice(this.t('file-link-copied'));
@ -820,8 +717,4 @@ export default class EasyCopy extends Plugin {
const lang = window.localStorage.getItem("language") || 'en';
return lang;
}
private encodeMarkdownLinkUrl(url: string): string {
return url.replace(/ /g, '%20');
}
}

7
vitest.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});