* feat(web): expand ContentViewer to render PDF, CSV, HTML, XML, JSON, CSS (closes #1329) Turn ContentViewer into a per-MIME dispatch surface for resource content. It now accepts raw TextResourceContents | BlobResourceContents (in addition to the existing ContentBlock path), so ResourcePreviewPanel.toContentBlock collapses to a passthrough and blob-typed resources render natively instead of falling back to opaque "[Binary content …]" text. New renderers (clients/web/src/components/elements/): - CodeHighlight: lazy react-syntax-highlighter prism-light element. Runtime, tomorrow theme, and each grammar dynamic-import on first use (one chunk per grammar). Module-level registeredLanguages / failedLoads / loadingPromises caches; plain Code block until a grammar resolves. - PdfFrame: iframe over a Blob URL (#view=FitH), revoked on unmount. - CsvTable: papaparse -> Mantine Table (first 100 rows), plain-Code fallback. - HtmlFrame: sandbox="" iframe over a Blob URL with an injected CSP meta (no script-src, falls through to default-src 'none'). - contentViewerUtils: getMimeKind, base64 decode helpers, formatXml, wrapHtmlWithCsp, SAFE_HREF markdown-anchor allowlist (unsafe href -> span). ResourcePreviewPanel: extend the URI-suffix MIME inference table (.csv/.json/.xml/.html/.htm/.css/.pdf alongside .md). Deps: papaparse + react-syntax-highlighter (+ @types). tsconfig paths stubs redirect the deep RSH imports for typecheck (the @types ambient declarations are shadowed by the real .js under bundler resolution) — same pattern the repo uses for react/pino. Tests cover PDF blob-URL creation + cleanup, CSV table render + fallback, HTML sandbox attributes + CSP injection, JSON/XML/CSS highlighted output, the lazy-load/alias/failed-load/shared-runtime paths, and the URI-suffix MIME-inference paths. validate, test:coverage (per-file gate), test:storybook, and integration all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): harden ContentViewer blob/CSV rendering per review (#1551) Address review feedback on the ContentViewer MIME-dispatch PR: - useObjectUrl: fix a dev-only StrictMode bug where the committed object URL was revoked but never recreated (setup→cleanup→setup with no re-render), blanking PDF/HTML previews. Revocation is now deferred to a microtask and guarded by a "live URL" ref so a StrictMode re-setup cancels it, while a real unmount / blob change still releases the URL. Avoids set-state-in-effect; mirrors AppRenderer's deferred-disposal. - atob: guard base64 decoding with tryDecodeBase64ToUtf8 / tryDecodeBase64ToBytes so a malformed blob from an external server degrades to the binary-content notice instead of throwing during render and taking down the whole preview panel. - CsvTable: surface the 100-row display cap with a "Showing first N of M rows" caption (no silent truncation). - Extract BinaryNotice into its own module so PdfFrame can reuse it without an import cycle. Tests added for the StrictMode path, malformed-base64 fallbacks (PDF + textual blob), the safe-decode helpers, and the truncation caption. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b2a6ee1dcf
commit
baf79b843f
1 changed files with 2193 additions and 110 deletions
|
|
@ -1,8 +1,26 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type {
|
||||
BlobResourceContents,
|
||||
ContentBlock,
|
||||
TextResourceContents,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ContentViewer } from "./ContentViewer";
|
||||
|
||||
// Stub the lazy highlighter so JSON/XML/CSS branches are assertable
|
||||
// synchronously (its real dynamic-import behavior is covered in
|
||||
// CodeHighlight.test.tsx).
|
||||
vi.mock("../CodeHighlight/CodeHighlight", () => ({
|
||||
CodeHighlight: ({ language, code }: { language: string; code: string }) => (
|
||||
<pre data-testid="code-highlight" data-language={language}>
|
||||
{code}
|
||||
</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
const toBase64 = (text: string): string =>
|
||||
Buffer.from(text, "utf-8").toString("base64");
|
||||
|
||||
describe("ContentViewer", () => {
|
||||
it("renders text content as-is when not JSON", () => {
|
||||
const block: ContentBlock = { type: "text", text: "hello world" };
|
||||
|
|
@ -185,4 +203,247 @@ describe("ContentViewer", () => {
|
|||
);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights a JSON text block when mimeType is application/json", () => {
|
||||
const block: ContentBlock = { type: "text", text: '{"a":1}' };
|
||||
renderWithMantine(
|
||||
<ContentViewer block={block} mimeType="application/json" />,
|
||||
);
|
||||
const probe = screen.getByTestId("code-highlight");
|
||||
expect(probe).toHaveAttribute("data-language", "json");
|
||||
expect(probe.textContent).toContain('"a": 1');
|
||||
});
|
||||
|
||||
it("renders nothing when neither block nor contents is provided", () => {
|
||||
const { container } = renderWithMantine(<ContentViewer />);
|
||||
// MantineProvider injects a <style> node; assert no actual content renders.
|
||||
expect(
|
||||
container.querySelector("pre, img, table, iframe, audio, code"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
describe("markdown anchor allowlist", () => {
|
||||
it("keeps safe-scheme anchors as links", () => {
|
||||
const block: ContentBlock = {
|
||||
type: "text",
|
||||
text: "[ok](https://example.com)",
|
||||
};
|
||||
renderWithMantine(
|
||||
<ContentViewer block={block} mimeType="text/markdown" />,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "ok" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("downgrades protocol-relative anchors to inert text", () => {
|
||||
const block: ContentBlock = {
|
||||
type: "text",
|
||||
text: "[bad](//evil.com)",
|
||||
};
|
||||
renderWithMantine(
|
||||
<ContentViewer block={block} mimeType="text/markdown" />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "bad" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("bad")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentViewer (resource contents)", () => {
|
||||
const text = (
|
||||
over: Partial<TextResourceContents> & { text: string },
|
||||
): TextResourceContents => ({ uri: "file:///r", ...over });
|
||||
const blob = (
|
||||
over: Partial<BlobResourceContents> & { blob: string },
|
||||
): BlobResourceContents => ({ uri: "file:///r", ...over });
|
||||
|
||||
it("renders markdown from text contents", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={text({ text: "# Title", mimeType: "text/markdown" })}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Title" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights JSON contents", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={text({ text: '{"a":1}', mimeType: "application/json" })}
|
||||
/>,
|
||||
);
|
||||
const probe = screen.getByTestId("code-highlight");
|
||||
expect(probe).toHaveAttribute("data-language", "json");
|
||||
expect(probe.textContent).toContain('"a": 1');
|
||||
});
|
||||
|
||||
it("indents and highlights XML contents", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={text({
|
||||
text: "<a><b>x</b></a>",
|
||||
mimeType: "application/xml",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const probe = screen.getByTestId("code-highlight");
|
||||
expect(probe).toHaveAttribute("data-language", "xml");
|
||||
expect(probe.textContent).toBe("<a>\n <b>x</b>\n</a>");
|
||||
});
|
||||
|
||||
it("highlights CSS contents", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer contents={text({ text: ".a{}", mimeType: "text/css" })} />,
|
||||
);
|
||||
expect(screen.getByTestId("code-highlight")).toHaveAttribute(
|
||||
"data-language",
|
||||
"css",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders CSV contents as a table", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={text({ text: "name,age\nAlice,30", mimeType: "text/csv" })}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "name" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("cell", { name: "Alice" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plain text contents in a code block", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={text({ text: "just text", mimeType: "text/plain" })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("just text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("decodes a blob delivered for a textual MIME", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={blob({
|
||||
blob: toBase64("name,age\nBob,25"),
|
||||
mimeType: "text/csv",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("cell", { name: "Bob" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the binary fallback when a textual blob fails to decode", () => {
|
||||
// Malformed base64 for a textual MIME would throw in `atob` during render;
|
||||
// the panel must degrade to the binary notice instead of crashing.
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={blob({ blob: "not%%base64", mimeType: "text/csv" })}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("[Binary content (text/csv) — preview not supported]"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an image blob as a data URI", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={blob({ blob: "AAAA", mimeType: "image/png" })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img")).toHaveAttribute(
|
||||
"src",
|
||||
"data:image/png;base64,AAAA",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an audio blob as a data URI", () => {
|
||||
const { container } = renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={blob({ blob: "BBBB", mimeType: "audio/mp3" })}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("source")).toHaveAttribute(
|
||||
"src",
|
||||
"data:audio/mp3;base64,BBBB",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the binary fallback for unsupported MIME types", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={blob({ blob: "AAAA", mimeType: "application/zip" })}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"[Binary content (application/zip) — preview not supported]",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to application/octet-stream when no MIME is known", () => {
|
||||
renderWithMantine(<ContentViewer contents={blob({ blob: "AAAA" })} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"[Binary content (application/octet-stream) — preview not supported]",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefers an explicit mimeType prop over the contents mimeType", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={text({ text: "# Heading", mimeType: "text/plain" })}
|
||||
mimeType="text/markdown"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Heading" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("blob-URL renderers", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:url");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a PDF blob in an iframe", () => {
|
||||
const { container } = renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={blob({
|
||||
blob: toBase64("%PDF"),
|
||||
mimeType: "application/pdf",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).toHaveAttribute("src", "blob:url#view=FitH");
|
||||
expect(iframe).toHaveAttribute("title", "PDF preview");
|
||||
});
|
||||
|
||||
it("renders an HTML resource in a sandboxed iframe", () => {
|
||||
const { container } = renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={text({ text: "<p>hi</p>", mimeType: "text/html" })}
|
||||
/>,
|
||||
);
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).toHaveAttribute("sandbox", "");
|
||||
expect(iframe).toHaveAttribute("src", "blob:url");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue