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 { buildHeadingLink, buildBlockLink, buildFileLink, extractBlockDisplayText, encodeMarkdownLinkUrl } from './linkBuilder';
import { buildHeadingLink, buildBlockLink, buildFileLink, extractBlockDisplayText, encodeMarkdownLinkUrl, sanitizeHeadingForLink } from './linkBuilder';
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
// ---------------------------------------------------------------------------
@ -190,29 +234,62 @@ describe('buildHeadingLink', () => {
});
});
// -- 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).
// -- Special character sanitization in link targets ----------------------
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).
describe('special character sanitization', () => {
it('wiki format: strips special chars from link target, preserves display text', () => {
const result = buildHeadingLink({
...defaults,
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({
...defaults,
heading: 'Java',
filename: 'JavaScript',
});
expect(result.link).toBe('[[JavaScript|Java]]');
expect(result.isNoteLink).toBe(true);
expect(result.link).toBe('[[JavaScript#Java|Java]]');
expect(result.isNoteLink).toBe(false);
});
it('does NOT simplify when heading contains filename as substring', () => {
// The includes check is directional: filename.includes(heading)
// "Note".includes("Notebook") → false
it('does not simplify when heading contains filename as substring', () => {
const result = buildHeadingLink({
...defaults,
heading: 'Notebook',
@ -222,17 +299,14 @@ describe('buildHeadingLink', () => {
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.
it('does not simplify on space-removed substring match', () => {
const result = buildHeadingLink({
...defaults,
heading: 'Some Thing',
filename: 'SomeThingElse',
});
expect(result.link).toBe('[[SomeThingElse|Some Thing]]');
expect(result.isNoteLink).toBe(true);
expect(result.link).toBe('[[SomeThingElse#Some Thing|Some Thing]]');
expect(result.isNoteLink).toBe(false);
});
});
@ -265,18 +339,17 @@ describe('buildHeadingLink', () => {
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.
it('empty heading produces a heading link with empty fragment (known bug)', () => {
// Empty heading is an edge case from Obsidian's UI. The link target
// is technically malformed (MyNote#) but this matches current behavior.
// Should be addressed in a future PR.
const result = buildHeadingLink({
...defaults,
heading: '',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote|]]');
expect(result.isNoteLink).toBe(true);
expect(result.link).toBe('[[MyNote#|]]');
expect(result.isNoteLink).toBe(false);
});
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');
}
// --- 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 ---
export interface BuildHeadingLinkOptions {
@ -41,18 +60,20 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading
displayText = `${filenameOrTitle}${separator}${selectedHeading}`;
}
let linkContent = `${filename}#${selectedHeading}`;
const sanitizedHeading = sanitizeHeadingForLink(selectedHeading);
let linkContent = `${filename}#${sanitizedHeading}`;
let isNoteLink = false;
const compareIgnoreCase = (a: string, b: string): boolean =>
a.toLowerCase() === b.toLowerCase() || a.toLowerCase().includes(b.toLowerCase());
a.toLowerCase() === b.toLowerCase();
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
if (
filename === selectedHeading ||
selectedHeading &&
(filename === selectedHeading ||
compareIgnoreCase(filename, selectedHeading) ||
compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, ''))
compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, '')))
) {
linkContent = filename;
isNoteLink = true;
@ -77,7 +98,7 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading
}
} else {
// Markdown链接格式
link = `[${displayText}](${encodeMarkdownLinkUrl(`${filename}#${selectedHeading}`)})`;
link = `[${displayText}](${encodeMarkdownLinkUrl(`${filename}#${sanitizedHeading}`)})`;
}
return { link, isNoteLink };