feat: add markdown image gallery previews

This commit is contained in:
ChangeHow
2026-08-13 16:19:06 +08:00
parent 8a6eca5597
commit f790b58d83
17 changed files with 818 additions and 155 deletions
@@ -1,7 +1,21 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
mock.module('dompurify', () => ({
default: {
isSupported: true,
addHook: () => undefined,
sanitize: (html: string) => html,
},
}));
mock.module('./markdown-worker', () => ({
highlightCodeInWorker: async () => null,
}));
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const { extractMarkdownImageCandidates, renderMarkdownSync } = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
describe('markdown sanitization', () => {
test('turns raw assistant HTML into inert visible text', () => {
const payload = '<style>@import url("https://example.test/theme.css");</style>';
@@ -16,3 +30,102 @@ describe('markdown sanitization', () => {
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
});
});
describe('Markdown images', () => {
test('keeps local image links in text and emits inert image placeholders', () => {
const html = renderMarkdownSync([
'[linked image](packages/vscode/extension.jpg)',
'![image syntax](packages/vscode/extension.jpg)',
].join('\n\n'), true);
expect(html).toContain('data-openchamber-markdown-image-link="true"');
expect(html.match(/data-openchamber-markdown-image-source="packages\/vscode\/extension.jpg"/g)).toHaveLength(1);
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
expect(html).toContain('image syntax');
expect(html).not.toContain('src="packages/vscode/extension.jpg"');
expect(html).not.toContain('data-openchamber-markdown-image-state');
expect(html.match(/<a /g)).toHaveLength(1);
});
test('keeps HTTP links as links and defers remote image tokens to finalized rendering', () => {
const html = renderMarkdownSync([
'[remote link](https://example.test/image.png)',
'![remote image](https://example.test/image.png)',
].join('\n\n'), true);
expect(html).toContain('<a href="https://example.test/image.png"');
expect(html).not.toContain('<img');
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
expect(html).toContain('remote image');
});
test('preserves file URLs inertly and never activates unknown schemes', () => {
const html = renderMarkdownSync([
'![file](file:///workspace/image.png)',
'![unsafe](javascript:alert(1))',
].join('\n\n'), true);
expect(html).toContain('role="img"');
expect(html).not.toContain('src="file:');
expect(html).not.toContain('src="javascript:');
});
test('collects a single ordered gallery across mixed Markdown and ignores code', () => {
const candidates = extractMarkdownImageCandidates([
[
'Before [local link](screens/first%20view.png) and `![code](ignored.png)`.',
'',
'- ![duplicate](screens/first%20view.png)',
'- ![remote](https://example.test/second.webp?size=2)',
'',
'```md',
'![fenced](ignored-too.jpg)',
'```',
].join('\n'),
'After ![third](data:image/png;base64,AAAA).',
]);
expect(candidates).toEqual([
{ source: 'screens/first%20view.png', filename: 'first view.png' },
{ source: 'https://example.test/second.webp?size=2', filename: 'second.webp' },
{ source: 'data:image/png;base64,AAAA', filename: 'third' },
]);
});
test('limits one finalized message gallery to twelve unique candidates', () => {
const markdown = Array.from({ length: 14 }, (_, index) => `![image ${index}](screens/${index}.png)`).join('\n');
const candidates = extractMarkdownImageCandidates([markdown]);
expect(candidates).toHaveLength(12);
expect(candidates.at(-1)?.source).toBe('screens/11.png');
});
test('validates embedded image bytes against the declared MIME type', async () => {
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const signal = new AbortController().signal;
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, '', signal)).toBe(`data:image/png;base64,${png}`);
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, '', signal).then(
() => { throw new Error('Expected mismatched image data to fail'); },
(error: unknown) => expect((error as Error).message).toBe('Unsupported image data'),
);
});
test('does not resolve images after cancellation', async () => {
const controller = new AbortController();
controller.abort();
await resolveMarkdownImageSource('https://example.test/image.png', '', controller.signal).then(
() => { throw new Error('Expected an aborted image load to fail'); },
(error: unknown) => expect((error as Error).name).toBe('AbortError'),
);
});
test('keeps the existing image renderer outside finalized assistant text', () => {
const html = renderMarkdownSync('![tool image](https://example.test/image.png)');
expect(html).toContain('<img src="https://example.test/image.png"');
expect(html).not.toContain('data-openchamber-markdown-image-placeholder');
});
});
@@ -1,4 +1,4 @@
import { marked, type Tokens } from 'marked';
import { Marked, marked, type Tokens } from 'marked';
import remend from 'remend';
import katex from 'katex';
import DOMPurify from 'dompurify';
@@ -10,6 +10,95 @@ import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecuri
const escapeAttr = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const LOCAL_IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp)(?:[?#].*)?$/i;
const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
const URL_SCHEME_RE = /^[A-Za-z][A-Za-z\d+.-]*:/;
export interface MarkdownImageCandidate {
source: string;
filename: string;
}
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
const isLocalMarkdownImageSource = (source: string): boolean => {
if (/^\/\//.test(source) || !LOCAL_IMAGE_EXTENSION_RE.test(source)) return false;
return WINDOWS_ABSOLUTE_PATH_RE.test(source)
|| /^file:\/\//i.test(source)
|| !URL_SCHEME_RE.test(source);
};
const isSupportedMarkdownImageSource = (source: string): boolean => (
/^(?:https?:)?\/\//i.test(source)
|| /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|| isLocalMarkdownImageSource(source)
);
export const getMarkdownImageFilename = (source: string, fallback: string): string => {
if (/^data:/i.test(source)) return fallback.trim();
const path = source.split(/[?#]/, 1)[0]?.replace(/\\/g, '/') ?? '';
const encodedName = path.split('/').filter(Boolean).at(-1) ?? '';
if (!encodedName) return fallback.trim();
try {
return decodeURIComponent(encodedName);
} catch {
return encodedName;
}
};
export const extractMarkdownImageCandidates = (
markdownTexts: readonly string[],
limit = MAX_MARKDOWN_IMAGE_COUNT,
): MarkdownImageCandidate[] => {
if (limit <= 0) return [];
const candidates: MarkdownImageCandidate[] = [];
const seen = new Set<string>();
for (const markdown of markdownTexts) {
if (!markdown || candidates.length >= limit) continue;
const tokens = marked.lexer(markdown);
marked.walkTokens(tokens, (token) => {
if (candidates.length >= limit) return;
if (token.type !== 'image' && token.type !== 'link') return;
if (token.type === 'link' && !isLocalMarkdownImageSource(token.href ?? '')) return;
const source = token.href ?? '';
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
const fallback = typeof token.text === 'string' ? token.text : '';
const filename = getMarkdownImageFilename(source, fallback);
if (!filename) return;
seen.add(source);
candidates.push({ source, filename });
});
}
return candidates;
};
const renderMarkdownImage = ({
href,
title,
text,
}: {
href: string;
title?: string | null;
text: string;
}): string => {
const source = href ?? '';
const alt = escapeAttr(text ?? '');
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
const supported = isSupportedMarkdownImageSource(source);
if (!supported) {
return `<span role="img" aria-label="${alt}"${titleAttr}>${alt}</span>`;
}
return `<span role="img" aria-label="${alt}"${titleAttr} data-openchamber-markdown-image-placeholder="true">${alt}</span>`;
};
// ---------------------------------------------------------------------------
// Streaming block segmentation (port of OpenCode's markdown-stream)
// ---------------------------------------------------------------------------
@@ -162,7 +251,7 @@ const blockMathExtension = {
},
};
const parser = marked.use({
const createParser = (deferImages: boolean) => new Marked().use({
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
@@ -175,6 +264,11 @@ const parser = marked.use({
},
link({ href, title, text }) {
const target = href ?? '';
if (deferImages && isLocalMarkdownImageSource(target)) {
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
const filename = getMarkdownImageFilename(target, '');
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer" data-openchamber-markdown-image-link="true" data-openchamber-markdown-image-source="${escapeAttr(target)}" data-openchamber-markdown-image-filename="${escapeAttr(filename)}">${text}</a>`;
}
const agentName = parseAgentHref(target);
if (agentName) {
return `<a href="${escapeAttr(buildAgentMentionUrl(agentName))}" data-openchamber-agent-mention="true" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`;
@@ -186,9 +280,13 @@ const parser = marked.use({
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
},
...(deferImages ? { image: renderMarkdownImage } : {}),
},
});
const parser = createParser(false);
const imageParser = createParser(true);
// ---------------------------------------------------------------------------
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
// ---------------------------------------------------------------------------
@@ -341,8 +439,8 @@ const touch = (key: string, entry: { hash: string; html: string }): void => {
if (oldest) htmlCache.delete(oldest);
};
const parseBlock = async (block: MarkdownBlock): Promise<string> => {
const parsed = await Promise.resolve(parser.parse(block.src));
const parseBlock = async (block: MarkdownBlock, deferImages: boolean): Promise<string> => {
const parsed = await Promise.resolve((deferImages ? imageParser : parser).parse(block.src));
const withMath = renderMathExpressions(parsed);
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
return sanitize(highlighted);
@@ -357,9 +455,9 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
* is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip.
*/
export const renderMarkdownSync = (text: string): string => {
export const renderMarkdownSync = (text: string, deferImages = false): string => {
if (!text) return '';
const parsed = parser.parse(text) as string;
const parsed = (deferImages ? imageParser : parser).parse(text) as string;
const withMath = renderMathExpressions(parsed);
return sanitize(withMath);
};
@@ -382,6 +480,7 @@ export const renderMarkdownBlocks = async (
text: string,
streaming: boolean,
cacheKey: string,
deferImages = false,
): Promise<RenderedBlock[]> => {
if (!text) return [];
@@ -389,14 +488,14 @@ export const renderMarkdownBlocks = async (
return Promise.all(
blocks.map(async (block, index) => {
const contentHash = hash(block.raw);
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`;
const key = `${cacheKey}:${index}:${block.mode}`;
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${deferImages ? 1 : 0}`;
const key = `${cacheKey}:${index}:${block.mode}:${deferImages ? 1 : 0}`;
const cached = htmlCache.get(key);
if (cached && cached.hash === contentHash) {
touch(key, cached);
return { id, html: cached.html };
}
const html = await parseBlock(block);
const html = await parseBlock(block, deferImages);
touch(key, { hash: contentHash, html });
return { id, html };
}),
@@ -0,0 +1,132 @@
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
import { runtimeFetch } from '@/lib/runtime-fetch';
const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
]);
const parseLocalImagePath = (source: string): string => {
let value = source;
if (/^file:\/\//i.test(value)) {
try {
const fileUrl = new URL(value);
if (fileUrl.protocol !== 'file:') return '';
value = fileUrl.host && fileUrl.host !== 'localhost'
? `//${fileUrl.host}${fileUrl.pathname}`
: fileUrl.pathname;
if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1);
} catch {
return '';
}
}
const path = value.split(/[?#]/, 1)[0] ?? '';
try {
return decodeURIComponent(path);
} catch {
return path;
}
};
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => typeof reader.result === 'string'
? resolve(reader.result)
: reject(new Error('Unable to encode image'));
reader.onerror = () => reject(reader.error ?? new Error('Unable to encode image'));
reader.readAsDataURL(blob);
});
const hasImageSignature = async (blob: Blob, mimeType: string): Promise<boolean> => {
const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end));
if (mimeType === 'image/png') {
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
}
if (mimeType === 'image/jpeg') {
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}
if (mimeType === 'image/gif') {
const gif = ascii(0, 6);
return gif === 'GIF87a' || gif === 'GIF89a';
}
return mimeType === 'image/webp' && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
};
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) throw new Error('Unsupported image type');
if (blob.size > MAX_MARKDOWN_IMAGE_BYTES) throw new Error('Image is too large');
if (!await hasImageSignature(blob, mimeType)) throw new Error('Unsupported image data');
};
const validateDataImage = async (source: string): Promise<void> => {
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([\s\S]*)$/i.exec(source);
if (!match?.[1] || match[2] === undefined) throw new Error('Invalid image data URL');
const encoded = match[2];
if (encoded.length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) {
throw new Error('Image is too large');
}
let binary: string;
try {
binary = atob(encoded);
} catch {
throw new Error('Invalid image data URL');
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
await validateImageBlob(new Blob([bytes]), match[1].toLowerCase());
};
export const resolveMarkdownImageSource = async (
source: string,
directory: string,
signal: AbortSignal,
): Promise<string> => {
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
if (/^(?:https?:)?\/\//i.test(source)) return source;
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
await validateDataImage(source);
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
return source;
}
const localPath = parseLocalImagePath(source);
const absolutePath = toAbsoluteFilePath(directory, localPath);
if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) {
throw new Error('Image path is outside the active workspace');
}
const statResponse = await runtimeFetch('/api/fs/stat', {
query: { path: absolutePath, directory, optional: 'true' },
signal,
});
if (!statResponse.ok) throw new Error(`Unable to inspect image (${statResponse.status})`);
const stat = await statResponse.json() as { isFile?: boolean; size?: number };
if (!stat.isFile) throw new Error('Image path is not a file');
if (typeof stat.size === 'number' && stat.size > MAX_MARKDOWN_IMAGE_BYTES) {
throw new Error('Image is too large');
}
const response = await runtimeFetch('/api/fs/raw', {
query: { path: absolutePath, directory },
signal,
});
if (!response.ok) throw new Error(`Unable to load image (${response.status})`);
const mimeType = (response.headers.get('content-type') ?? '').split(';', 1)[0]?.toLowerCase() ?? '';
const contentLength = Number(response.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_MARKDOWN_IMAGE_BYTES) {
throw new Error('Image is too large');
}
const blob = await response.blob();
await validateImageBlob(blob, mimeType);
return blobToDataUrl(blob);
};