fix: sanitize heading link targets and fix substring matching

Strip # | ^ : %% [[ ]] from heading link targets to match Obsidian's
autocomplete behavior. Fix compareIgnoreCase to use strict equality
instead of includes(), preventing false-positive note-link simplification.
This commit is contained in:
Andre Light 2026-04-13 18:13:09 -06:00 committed by Moy
parent d6f6678b38
commit 60e451db9f
2 changed files with 128 additions and 34 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { buildHeadingLink, buildBlockLink, buildFileLink, extractBlockDisplayText, encodeMarkdownLinkUrl } from './linkBuilder'; import { buildHeadingLink, buildBlockLink, buildFileLink, extractBlockDisplayText, encodeMarkdownLinkUrl, sanitizeHeadingForLink } from './linkBuilder';
import { LinkFormat } from './type'; import { LinkFormat } from './type';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -24,6 +24,50 @@ describe('encodeMarkdownLinkUrl', () => {
}); });
}); });
// ---------------------------------------------------------------------------
// sanitizeHeadingForLink
// ---------------------------------------------------------------------------
describe('sanitizeHeadingForLink', () => {
it('strips # | ^ : with surrounding whitespace', () => {
expect(sanitizeHeadingForLink('Test | If # This ^ Heading : Works'))
.toBe('Test If This Heading Works');
});
it('strips special chars without surrounding whitespace', () => {
expect(sanitizeHeadingForLink('Test|If#This^Heading:Works, Too'))
.toBe('Test If This Heading Works, Too');
});
it('strips only the special chars that are present', () => {
expect(sanitizeHeadingForLink('Test | If This ^ Heading Works (No Hash or Colon)'))
.toBe('Test If This Heading Works (No Hash or Colon)');
});
it('strips %% (comment marker)', () => {
expect(sanitizeHeadingForLink('Before %% comment %% After'))
.toBe('Before comment After');
});
it('strips [[ and ]]', () => {
expect(sanitizeHeadingForLink('See [[Other Note]] here'))
.toBe('See Other Note here');
});
it('returns heading unchanged when no special chars', () => {
expect(sanitizeHeadingForLink('Normal Heading Text'))
.toBe('Normal Heading Text');
});
it('handles empty string', () => {
expect(sanitizeHeadingForLink('')).toBe('');
});
it('handles heading that is only special chars', () => {
expect(sanitizeHeadingForLink('# | ^')).toBe('');
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// buildHeadingLink // buildHeadingLink
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -190,29 +234,62 @@ describe('buildHeadingLink', () => {
}); });
}); });
// -- compareIgnoreCase `includes` behavior -------------------------------- // -- Special character sanitization in link targets ----------------------
// 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)', () => { describe('special character sanitization', () => {
it('false-positive: simplifies when filename contains heading as substring', () => { it('wiki format: strips special chars from link target, preserves display text', () => {
// BUG: "JavaScript".includes("Java") → true, incorrectly simplifies. const result = buildHeadingLink({
// Expected correct behavior: should NOT simplify (not an exact or ...defaults,
// derived-name match). heading: 'Test | If # This ^ Heading : Works',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#Test If This Heading Works|Test | If # This ^ Heading : Works]]');
});
it('markdown format: strips special chars from URL target', () => {
const result = buildHeadingLink({
...defaults,
linkFormat: LinkFormat.MDLINK,
heading: 'Test | If # This ^ Heading : Works',
filename: 'MyNote',
});
expect(result.link).toBe('[Test | If # This ^ Heading : Works](MyNote#Test%20If%20This%20Heading%20Works)');
});
it('wiki format: strips chars without surrounding whitespace', () => {
const result = buildHeadingLink({
...defaults,
heading: 'Test|If#This^Heading:Works, Too',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#Test If This Heading Works, Too|Test|If#This^Heading:Works, Too]]');
});
it('markdown format: display text preserves original heading', () => {
const result = buildHeadingLink({
...defaults,
linkFormat: LinkFormat.MDLINK,
heading: 'Test|If#This^Heading:Works, Too',
filename: 'MyNote',
});
expect(result.link).toBe('[Test|If#This^Heading:Works, Too](MyNote#Test%20If%20This%20Heading%20Works,%20Too)');
});
});
// -- compareIgnoreCase: strict equality (no substring matching) -----------
describe('filename-heading matching (strict equality)', () => {
it('does not simplify when filename contains heading as substring', () => {
const result = buildHeadingLink({ const result = buildHeadingLink({
...defaults, ...defaults,
heading: 'Java', heading: 'Java',
filename: 'JavaScript', filename: 'JavaScript',
}); });
expect(result.link).toBe('[[JavaScript|Java]]'); expect(result.link).toBe('[[JavaScript#Java|Java]]');
expect(result.isNoteLink).toBe(true); expect(result.isNoteLink).toBe(false);
}); });
it('does NOT simplify when heading contains filename as substring', () => { 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({ const result = buildHeadingLink({
...defaults, ...defaults,
heading: 'Notebook', heading: 'Notebook',
@ -222,17 +299,14 @@ describe('buildHeadingLink', () => {
expect(result.isNoteLink).toBe(false); expect(result.isNoteLink).toBe(false);
}); });
it('false-positive: simplifies on space-removed substring match', () => { it('does not simplify 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({ const result = buildHeadingLink({
...defaults, ...defaults,
heading: 'Some Thing', heading: 'Some Thing',
filename: 'SomeThingElse', filename: 'SomeThingElse',
}); });
expect(result.link).toBe('[[SomeThingElse|Some Thing]]'); expect(result.link).toBe('[[SomeThingElse#Some Thing|Some Thing]]');
expect(result.isNoteLink).toBe(true); expect(result.isNoteLink).toBe(false);
}); });
}); });
@ -265,18 +339,17 @@ describe('buildHeadingLink', () => {
expect(result.isNoteLink).toBe(true); expect(result.isNoteLink).toBe(true);
}); });
it('empty heading triggers false note-link simplification (known bug)', () => { it('empty heading produces a heading link with empty fragment (known bug)', () => {
// BUG: ''.includes('') is always true in JS, so compareIgnoreCase // Empty heading is an edge case from Obsidian's UI. The link target
// falsely matches any filename. Should produce a heading link with // is technically malformed (MyNote#) but this matches current behavior.
// an empty fragment instead. Fix alongside compareIgnoreCase in a // Should be addressed in a future PR.
// future PR.
const result = buildHeadingLink({ const result = buildHeadingLink({
...defaults, ...defaults,
heading: '', heading: '',
filename: 'MyNote', filename: 'MyNote',
}); });
expect(result.link).toBe('[[MyNote|]]'); expect(result.link).toBe('[[MyNote#|]]');
expect(result.isNoteLink).toBe(true); expect(result.isNoteLink).toBe(false);
}); });
it('markdown format URL-encodes spaces in link URL', () => { it('markdown format URL-encodes spaces in link URL', () => {

View file

@ -6,6 +6,25 @@ export function encodeMarkdownLinkUrl(url: string): string {
return url.replace(/ /g, '%20'); return url.replace(/ /g, '%20');
} }
// --- Heading Sanitization ---
/**
* Sanitize heading text for use in link targets.
*
* Obsidian's [[ autocomplete surprisingly strips # | ^ : %% [[ ]] from
* heading link targets rather than URL-encoding them, replacing each
* occurrence (and any surrounding whitespace) with a single space.
* URL-encoding these characters does not work reliably in Obsidian.
*
* See: https://help.obsidian.md/Linking+notes+and+files/Internal+links
*/
export function sanitizeHeadingForLink(heading: string): string {
return heading
.replace(/\s*(?:%%|\[\[|]]|[#|^:])\s*/g, ' ')
.replace(/ {2,}/g, ' ')
.trim();
}
// --- Heading Link --- // --- Heading Link ---
export interface BuildHeadingLinkOptions { export interface BuildHeadingLinkOptions {
@ -41,18 +60,20 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading
displayText = `${filenameOrTitle}${separator}${selectedHeading}`; displayText = `${filenameOrTitle}${separator}${selectedHeading}`;
} }
let linkContent = `${filename}#${selectedHeading}`; const sanitizedHeading = sanitizeHeadingForLink(selectedHeading);
let linkContent = `${filename}#${sanitizedHeading}`;
let isNoteLink = false; let isNoteLink = false;
const compareIgnoreCase = (a: string, b: string): boolean => const compareIgnoreCase = (a: string, b: string): boolean =>
a.toLowerCase() === b.toLowerCase() || a.toLowerCase().includes(b.toLowerCase()); a.toLowerCase() === b.toLowerCase();
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分 // 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分 // 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
if ( if (
filename === selectedHeading || selectedHeading &&
(filename === selectedHeading ||
compareIgnoreCase(filename, selectedHeading) || compareIgnoreCase(filename, selectedHeading) ||
compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, '')) compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, '')))
) { ) {
linkContent = filename; linkContent = filename;
isNoteLink = true; isNoteLink = true;
@ -77,7 +98,7 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading
} }
} else { } else {
// Markdown链接格式 // Markdown链接格式
link = `[${displayText}](${encodeMarkdownLinkUrl(`${filename}#${selectedHeading}`)})`; link = `[${displayText}](${encodeMarkdownLinkUrl(`${filename}#${sanitizedHeading}`)})`;
} }
return { link, isNoteLink }; return { link, isNoteLink };