Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions packages/annotation/src/AnnotatedMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { asset } from '@contextbridge/shared/testFactories';
import { render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it } from 'vitest';
import { AnnotatedMarkdown } from './AnnotatedMarkdown.tsx';

describe('AnnotatedMarkdown image rendering', () => {
const fixtureAsset = asset.build({
id: 'abc123',
originalPath: '/tmp/fixture.png',
});

it('rewrites local-path img srcs to /assets/<id> when a matching asset is provided', () => {
render(
<AnnotatedMarkdown
content="![fixture](/tmp/fixture.png)"
assets={[fixtureAsset]}
containerRef={createRef<HTMLDivElement>()}
/>,
);
const img = screen.getByAltText('fixture');
expect(img.getAttribute('src')).toBe('/assets/abc123');
});

it('leaves remote img srcs unchanged', () => {
render(
<AnnotatedMarkdown
content="![cat](https://example.com/cat.png)"
assets={[fixtureAsset]}
containerRef={createRef<HTMLDivElement>()}
/>,
);
const img = screen.getByAltText('cat');
expect(img.getAttribute('src')).toBe('https://example.com/cat.png');
});

it('leaves local-path img srcs unchanged when assets prop is omitted', () => {
render(<AnnotatedMarkdown content="![diagram](/tmp/missing.png)" containerRef={createRef<HTMLDivElement>()} />);
const img = screen.getByAltText('diagram');
expect(img.getAttribute('src')).toBe('/tmp/missing.png');
});
});
20 changes: 17 additions & 3 deletions packages/annotation/src/AnnotatedMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AnnotationTargetKind } from '@contextbridge/shared/annotationSchema';
import type { AnnotationTargetKind, Asset } from '@contextbridge/shared/annotationSchema';
import { cn } from '@contextbridge/ui/lib/utils';
import { createElement } from 'react';
import type { ComponentPropsWithoutRef, ComponentType, JSX, Ref } from 'react';
Expand All @@ -14,9 +14,23 @@ export interface AnnotatedMarkdownProps {
content: string;
containerRef: Ref<HTMLDivElement>;
onMouseUp?: () => void;
assets?: Asset[];
}

export function AnnotatedMarkdown({ content, containerRef, onMouseUp }: AnnotatedMarkdownProps) {
export function AnnotatedMarkdown({ content, containerRef, onMouseUp, assets }: AnnotatedMarkdownProps) {
const assetsByPath = new Map<string, Asset>();
for (const asset of assets ?? []) {
assetsByPath.set(asset.originalPath, asset);
}

const components = {
...markdownComponents,
img: ({ src, alt, ...rest }: ComponentPropsWithoutRef<'img'>) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snags images defined in our assets metadata and serves them from the purpose-built CLI endpoint.

const match = assetsByPath.get(src ?? '');
return <img src={match ? `/assets/${match.id}` : src} alt={alt} {...rest} />;
},
};

return (
<div
ref={containerRef}
Expand All @@ -27,7 +41,7 @@ export function AnnotatedMarkdown({ content, containerRef, onMouseUp }: Annotate
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
components={markdownComponents}
components={components}
>
{content}
</ReactMarkdown>
Expand Down
1 change: 1 addition & 0 deletions packages/annotation/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export function App({ initialPayload, initialThreads, initialGlobalComment }: Ap
key={payload.content}
containerRef={annotationInteractions.handlePlanContainer}
content={payload.content}
assets={payload.assets}
onMouseUp={annotationInteractions.handleSelectionCapture}
/>
) : (
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
"neverthrow": "^8.2.0",
"open": "^11.0.0",
"pino": "^10.3.1",
"remark-parse": "^11.0.0",
"semver": "^7.7.4",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"zod": "^4.3.6"
},
"devDependencies": {
Expand Down
183 changes: 183 additions & 0 deletions packages/cli/src/annotation/extractAssets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { type TestContext, createStubContext, readLogs } from '#src/testHelpers/index.ts';
import { MAX_ASSET_BYTES, MAX_TOTAL_ASSET_BYTES, extractAssets } from './extractAssets.ts';

describe('extractAssets', () => {
let stub: TestContext;
let tmp: string;

beforeEach(() => {
stub = createStubContext();
tmp = mkdtempSync(join(tmpdir(), 'cb-extract-assets-'));
});

afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});

it('returns an asset for a single absolute-path image reference that exists on disk', async () => {
const imgPath = join(tmp, 'a.png');
writeFileSync(imgPath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));

const assets = await extractAssets(stub.context, {
content: `# plan\n\n![diagram](${imgPath})\n`,
});

expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({
originalPath: imgPath,
mimeType: 'image/png',
});
expect(assets[0]?.id).toMatch(/^[0-9a-f]{12}$/);
expect(assets[0]?.dataBase64).toBe('iVBORw==');
});

it('returns an asset for a file URL image reference', async () => {
const imgPath = join(tmp, 'file-url.png');
writeFileSync(imgPath, Buffer.from([0x03, 0x04]));
const imgUrl = pathToFileURL(imgPath).toString();

const assets = await extractAssets(stub.context, {
content: `![diagram](${imgUrl})\n`,
});

expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({
originalPath: imgUrl,
mimeType: 'image/png',
dataBase64: 'AwQ=',
});
});

it('dedupes two references with the same as-written path string', async () => {
const imgPath = join(tmp, 'b.png');
writeFileSync(imgPath, Buffer.from([0x01, 0x02]));

const assets = await extractAssets(stub.context, {
content: `![one](${imgPath})\n\n![two](${imgPath})\n`,
});

expect(assets).toHaveLength(1);
});

it('skips non-local URLs silently', async () => {
const assets = await extractAssets(stub.context, {
content: '![cat](https://example.com/cat.png)\n![inline](data:image/png;base64,iVBORw0KGgo=)\n',
});

expect(assets).toHaveLength(0);
});

it('skips paths whose extension is not in the allowlist (including SVG)', async () => {
const svg = join(tmp, 'c.svg');
writeFileSync(svg, '<svg/>');

const assets = await extractAssets(stub.context, {
content: `![bad](${svg})\n![nope](/x.txt)\n`,
});

expect(assets).toHaveLength(0);
});

it('resolves relative paths against sourcePath when sourcePath is set', async () => {
writeFileSync(join(tmp, 'side.png'), Buffer.from([0xff]));
const sourcePath = join(tmp, 'plan.md');
writeFileSync(sourcePath, '');

const assets = await extractAssets(stub.context, {
content: '![side](./side.png)\n',
sourcePath,
});

expect(assets).toHaveLength(1);
expect(assets[0]?.originalPath).toBe('./side.png');
});

it('resolves relative paths against process.cwd() when sourcePath is unset (stdin case)', async () => {
const originalCwd = process.cwd();
writeFileSync(join(tmp, 'side.png'), Buffer.from([0xfe]));
process.chdir(tmp);
try {
const assets = await extractAssets(stub.context, {
content: '![side](./side.png)\n',
});
expect(assets).toHaveLength(1);
expect(assets[0]?.originalPath).toBe('./side.png');
} finally {
process.chdir(originalCwd);
}
});

it('logs error and skips when a referenced image cannot be read', async () => {
const missing = join(tmp, 'missing.png');

const assets = await extractAssets(stub.context, {
content: `![ghost](${missing})\n`,
});

expect(assets).toHaveLength(0);
const logs = readLogs(stub.logs);
expect(logs.some((log) => log.level === 50 && log.msg === 'failed to read referenced image')).toBe(true);
});

it('logs warning and skips empty image files', async () => {
const empty = join(tmp, 'empty.png');
writeFileSync(empty, Buffer.from([]));

const assets = await extractAssets(stub.context, {
content: `![empty](${empty})\n`,
});

expect(assets).toHaveLength(0);
const logs = readLogs(stub.logs);
expect(logs.some((log) => log.level === 40 && log.msg === 'referenced image is empty')).toBe(true);
});

it('logs warning and skips images over the per-asset size limit', async () => {
const oversized = join(tmp, 'oversized.png');
writeFileSync(oversized, '');
truncateSync(oversized, MAX_ASSET_BYTES + 1);

const assets = await extractAssets(stub.context, {
content: `![oversized](${oversized})\n`,
});

expect(assets).toHaveLength(0);
const logs = readLogs(stub.logs);
expect(logs.some((log) => log.level === 40 && log.msg === 'referenced image exceeds per-asset size limit')).toBe(
true,
);
});

it('logs warning and skips images that would exceed the total size limit', async () => {
const first = join(tmp, 'first.png');
const second = join(tmp, 'second.png');
const overflow = join(tmp, 'overflow.png');
writeFileSync(first, '');
writeFileSync(second, '');
writeFileSync(overflow, '');
truncateSync(first, MAX_ASSET_BYTES);
truncateSync(second, MAX_ASSET_BYTES);
truncateSync(overflow, MAX_TOTAL_ASSET_BYTES - 2 * MAX_ASSET_BYTES + 1);

const assets = await extractAssets(stub.context, {
content: `![first](${first})\n![second](${second})\n![overflow](${overflow})\n`,
});

expect(assets).toHaveLength(2);
const logs = readLogs(stub.logs);
expect(logs.some((log) => log.level === 40 && log.msg === 'referenced images exceed total size limit')).toBe(true);
});

it('does not extract images inside fenced code blocks', async () => {
const assets = await extractAssets(stub.context, {
content: '```\n![not-an-image](/should/not/extract.png)\n```\n',
});

expect(assets).toHaveLength(0);
});
});
Loading