yishentu--claudian
408 行
14 KiB
TypeScript
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('<script>');
|
|
});
|
|
});
|
|
|
|
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('<script>');
|
|
});
|
|
|
|
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('<b>');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|