项目文件夹

文件
wehub-resource-sync 2771cff92b
CI / lint (push) Failing after 1s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 1s
CI / build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:22:56 +08:00

408 行
14 KiB
TypeScript

import type { App, TFile } from 'obsidian';
import { replaceImageEmbedsWithHtml } from '@/utils/imageEmbed';
type LinkResolver = (
linkPath: string,
sourcePath: string,
files: Map<string, TFile>
) => TFile | null | undefined;
// Mock App and vault for testing
function createMockApp(files: Map<string, string> = new Map(), resolveLink?: LinkResolver): App {
const mockFiles = new Map<string, TFile>();
files.forEach((resourcePath, filePath) => {
mockFiles.set(filePath, {
path: filePath,
basename: filePath.split('/').pop()?.replace(/\.[^.]+$/, '') || filePath,
} as TFile);
});
return {
vault: {
getAbstractFileByPath: (path: string) => mockFiles.get(path) || null,
getResourcePath: (file: TFile) => files.get(file.path) || `app://local/${file.path}`,
},
metadataCache: {
getFirstLinkpathDest: (linkPath: string, sourcePath: string) => {
const resolved = resolveLink?.(linkPath, sourcePath, mockFiles);
if (resolved !== undefined) {
return resolved;
}
// Try to find by basename
for (const [path, file] of mockFiles) {
if (path.endsWith(linkPath) || path === linkPath) {
return file;
}
}
return null;
},
},
} as unknown as App;
}
describe('replaceImageEmbedsWithHtml', () => {
describe('basic image embeds', () => {
it('replaces simple image embed with img tag', () => {
const app = createMockApp(new Map([['image.png', 'app://local/image.png']]));
const result = replaceImageEmbedsWithHtml('![[image.png]]', app);
expect(result).toContain('<img');
expect(result).toContain('src="app://local/image.png"');
expect(result).toContain('class="claudian-embedded-image"');
});
it('replaces image embed with folder path', () => {
const app = createMockApp(new Map([['assets/photo.jpg', 'app://local/assets/photo.jpg']]));
const result = replaceImageEmbedsWithHtml('![[assets/photo.jpg]]', app);
expect(result).toContain('src="app://local/assets/photo.jpg"');
});
it('handles image in surrounding text', () => {
const app = createMockApp(new Map([['test.png', 'app://local/test.png']]));
const result = replaceImageEmbedsWithHtml('Check this ![[test.png]] image', app);
expect(result).toContain('Check this');
expect(result).toContain('image');
expect(result).toContain('<img');
});
});
describe('alt text and dimensions', () => {
it('uses alt text from wikilink', () => {
const app = createMockApp(new Map([['image.png', 'app://local/image.png']]));
const result = replaceImageEmbedsWithHtml('![[image.png|My Alt Text]]', app);
expect(result).toContain('alt="My Alt Text"');
});
it('applies width dimension from alt text', () => {
const app = createMockApp(new Map([['image.png', 'app://local/image.png']]));
const result = replaceImageEmbedsWithHtml('![[image.png|300]]', app);
expect(result).toContain('style="width: 300px;"');
});
it('applies width and height dimensions', () => {
const app = createMockApp(new Map([['image.png', 'app://local/image.png']]));
const result = replaceImageEmbedsWithHtml('![[image.png|200x150]]', app);
expect(result).toContain('style="width: 200px; height: 150px;"');
});
it('uses basename as alt when no alt text provided', () => {
const app = createMockApp(new Map([['folder/my-image.png', 'app://local/folder/my-image.png']]));
const result = replaceImageEmbedsWithHtml('![[folder/my-image.png]]', app);
expect(result).toContain('alt="my-image"');
});
});
describe('supported image extensions', () => {
const extensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'];
extensions.forEach((ext) => {
it(`replaces .${ext} image embed`, () => {
const app = createMockApp(new Map([[`image.${ext}`, `app://local/image.${ext}`]]));
const result = replaceImageEmbedsWithHtml(`![[image.${ext}]]`, app);
expect(result).toContain('<img');
expect(result).toContain(`src="app://local/image.${ext}"`);
});
});
it('handles uppercase extensions (case-insensitive)', () => {
const app = createMockApp(new Map([['photo.PNG', 'app://local/photo.PNG']]));
const result = replaceImageEmbedsWithHtml('![[photo.PNG]]', app);
expect(result).toContain('<img');
});
it('handles mixed case extensions', () => {
const app = createMockApp(new Map([['image.JpG', 'app://local/image.JpG']]));
const result = replaceImageEmbedsWithHtml('![[image.JpG]]', app);
expect(result).toContain('<img');
});
});
describe('non-image embeds (should pass through)', () => {
it('leaves markdown file embed unchanged', () => {
const app = createMockApp();
const result = replaceImageEmbedsWithHtml('![[note.md]]', app);
expect(result).toBe('![[note.md]]');
});
it('leaves pdf embed unchanged', () => {
const app = createMockApp();
const result = replaceImageEmbedsWithHtml('![[document.pdf]]', app);
expect(result).toBe('![[document.pdf]]');
});
it('leaves audio embed unchanged', () => {
const app = createMockApp();
const result = replaceImageEmbedsWithHtml('![[audio.mp3]]', app);
expect(result).toBe('![[audio.mp3]]');
});
it('processes only image embeds in mixed content', () => {
const app = createMockApp(new Map([['image.png', 'app://local/image.png']]));
const result = replaceImageEmbedsWithHtml('![[note.md]] and ![[image.png]]', app);
expect(result).toContain('![[note.md]]');
expect(result).toContain('<img');
});
});
describe('file not found (fallback)', () => {
it('shows fallback when image file not found', () => {
const app = createMockApp(); // Empty vault
const result = replaceImageEmbedsWithHtml('![[missing.png]]', app);
expect(result).toContain('class="claudian-embedded-image-fallback"');
expect(result).toContain('![[missing.png]]');
});
it('escapes HTML in fallback wikilink', () => {
const app = createMockApp();
const result = replaceImageEmbedsWithHtml('![[<script>alert(1)</script>.png]]', app);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
});
describe('media folder resolution', () => {
it('resolves image from media folder', () => {
const app = createMockApp(new Map([['attachments/photo.png', 'app://local/attachments/photo.png']]));
const result = replaceImageEmbedsWithHtml('![[photo.png]]', app, 'attachments');
expect(result).toContain('src="app://local/attachments/photo.png"');
});
it('prefers direct path over media folder', () => {
const app = createMockApp(
new Map([
['image.png', 'app://local/image.png'],
['attachments/image.png', 'app://local/attachments/image.png'],
])
);
const result = replaceImageEmbedsWithHtml('![[image.png]]', app, 'attachments');
expect(result).toContain('src="app://local/image.png"');
});
});
describe('source path resolution', () => {
it('passes source path into Obsidian link resolution for ambiguous image embeds', () => {
const app = createMockApp(
new Map([
['section-a/image.png', 'app://local/section-a/image.png'],
['section-b/image.png', 'app://local/section-b/image.png'],
]),
(linkPath, sourcePath, files) => {
if (linkPath === 'image.png' && sourcePath === 'section-b/note.md') {
return files.get('section-b/image.png');
}
return files.get('section-a/image.png');
}
);
const result = replaceImageEmbedsWithHtml('![[image.png]]', app, {
sourcePath: 'section-b/note.md',
});
expect(result).toContain('src="app://local/section-b/image.png"');
expect(result).not.toContain('section-a/image.png');
});
});
describe('multiple image embeds', () => {
it('replaces multiple images in text', () => {
const app = createMockApp(
new Map([
['a.png', 'app://local/a.png'],
['b.png', 'app://local/b.png'],
])
);
const result = replaceImageEmbedsWithHtml('![[a.png]] and ![[b.png]]', app);
expect(result).toContain('src="app://local/a.png"');
expect(result).toContain('src="app://local/b.png"');
});
it('replaces consecutive image embeds', () => {
const app = createMockApp(
new Map([
['1.png', 'app://local/1.png'],
['2.png', 'app://local/2.png'],
])
);
const result = replaceImageEmbedsWithHtml('![[1.png]]![[2.png]]', app);
expect((result.match(/<img/g) || []).length).toBe(2);
});
});
describe('HTML escaping (security)', () => {
it('escapes HTML in image src', () => {
const app = createMockApp(new Map([['test.png', 'app://local/path"><script>alert(1)</script>']]));
const result = replaceImageEmbedsWithHtml('![[test.png]]', app);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('escapes HTML in alt text', () => {
const app = createMockApp(new Map([['test.png', 'app://local/test.png']]));
const result = replaceImageEmbedsWithHtml('![[test.png|<b>bold</b>]]', app);
expect(result).not.toContain('<b>');
expect(result).toContain('&lt;b&gt;');
});
});
describe('edge cases', () => {
it('handles empty string', () => {
const app = createMockApp();
const result = replaceImageEmbedsWithHtml('', app);
expect(result).toBe('');
});
it('handles text without embeds', () => {
const app = createMockApp();
const result = replaceImageEmbedsWithHtml('Just plain text', app);
expect(result).toBe('Just plain text');
});
it('handles incomplete embed syntax', () => {
const app = createMockApp();
const result = replaceImageEmbedsWithHtml('![[incomplete', app);
expect(result).toBe('![[incomplete');
});
it('handles multiple sequential calls (regex lastIndex reset)', () => {
const app = createMockApp(new Map([['a.png', 'app://local/a.png']]));
// First call
const result1 = replaceImageEmbedsWithHtml('![[a.png]]', app);
// Second call - would fail without lastIndex reset
const result2 = replaceImageEmbedsWithHtml('![[a.png]]', app);
expect(result1).toContain('<img');
expect(result2).toContain('<img');
});
it('replaces image embeds in multiline content', () => {
const app = createMockApp(new Map([['test.png', 'app://local/test.png']]));
const result = replaceImageEmbedsWithHtml(
'First paragraph\n\n![[test.png]]\n\nThird paragraph',
app
);
expect(result).toContain('<img');
expect(result).toContain('First paragraph');
expect(result).toContain('Third paragraph');
});
it('handles special characters in filename', () => {
const filename = 'photo (2024-01-01).png';
const app = createMockApp(new Map([[filename, `app://local/${filename}`]]));
const result = replaceImageEmbedsWithHtml(`![[${filename}]]`, app);
expect(result).toContain('<img');
});
it('handles spaces in filename', () => {
const filename = 'my long image name.png';
const app = createMockApp(new Map([[filename, `app://local/${filename}`]]));
const result = replaceImageEmbedsWithHtml(`![[${filename}]]`, app);
expect(result).toContain('<img');
});
it('handles deep folder paths', () => {
const path = 'a/b/c/d/image.png';
const app = createMockApp(new Map([[path, `app://local/${path}`]]));
const result = replaceImageEmbedsWithHtml(`![[${path}]]`, app);
expect(result).toContain('<img');
});
it('includes lazy loading attribute', () => {
const app = createMockApp(new Map([['test.png', 'app://local/test.png']]));
const result = replaceImageEmbedsWithHtml('![[test.png]]', app);
expect(result).toContain('loading="lazy"');
});
});
describe('error handling', () => {
it('returns unchanged markdown when app is not initialized', () => {
// @ts-expect-error - testing invalid input
const result = replaceImageEmbedsWithHtml('![[image.png]]', null);
expect(result).toBe('![[image.png]]');
});
it('returns unchanged markdown when vault is missing', () => {
const app = { metadataCache: {} } as unknown as App;
const result = replaceImageEmbedsWithHtml('![[image.png]]', app);
expect(result).toBe('![[image.png]]');
});
it('returns unchanged markdown when metadataCache is missing', () => {
const app = { vault: {} } as unknown as App;
const result = replaceImageEmbedsWithHtml('![[image.png]]', app);
expect(result).toBe('![[image.png]]');
});
it('returns fallback when getResourcePath throws', () => {
const mockFile = { path: 'test.png', basename: 'test' } as TFile;
const app = {
vault: {
getAbstractFileByPath: () => mockFile,
getResourcePath: () => {
throw new Error('Resource path failed');
},
},
metadataCache: {
getFirstLinkpathDest: () => null,
},
} as unknown as App;
const result = replaceImageEmbedsWithHtml('![[test.png]]', app);
expect(result).toContain('class="claudian-embedded-image-fallback"');
});
});
describe('regular wikilinks (should NOT match)', () => {
it('does not replace regular wikilink', () => {
const app = createMockApp();
const result = replaceImageEmbedsWithHtml('[[note.md]]', app);
expect(result).toBe('[[note.md]]');
expect(result).not.toContain('<img');
});
it('processes image embed but not file link', () => {
const app = createMockApp(new Map([['image.png', 'app://local/image.png']]));
const result = replaceImageEmbedsWithHtml('[[note.md]] and ![[image.png]]', app);
expect(result).toContain('[[note.md]]');
expect(result).toContain('<img');
});
});
});