feat: add markdown image gallery previews
This commit is contained in:
@@ -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)',
|
||||
'',
|
||||
].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)',
|
||||
'',
|
||||
].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([
|
||||
'',
|
||||
')',
|
||||
].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 ``.',
|
||||
'',
|
||||
'- ',
|
||||
'- ',
|
||||
'',
|
||||
'```md',
|
||||
'',
|
||||
'```',
|
||||
].join('\n'),
|
||||
'After .',
|
||||
]);
|
||||
|
||||
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) => ``).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('');
|
||||
|
||||
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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);
|
||||
};
|
||||
Reference in New Issue
Block a user