* 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
2 changed files with 2193 additions and 110 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type {
|
||||
BlobResourceContents,
|
||||
|
|
@ -8,6 +8,16 @@ import type {
|
|||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ResourcePreviewPanel } from "./ResourcePreviewPanel";
|
||||
|
||||
// Stub the lazy highlighter so JSON/XML/CSS branches render synchronously
|
||||
// (its dynamic-import behavior is covered in CodeHighlight.test.tsx).
|
||||
vi.mock("../../elements/CodeHighlight/CodeHighlight", () => ({
|
||||
CodeHighlight: ({ language, code }: { language: string; code: string }) => (
|
||||
<pre data-testid="code-highlight" data-language={language}>
|
||||
{code}
|
||||
</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
const textResource: Resource = {
|
||||
name: "config.json",
|
||||
uri: "file:///config.json",
|
||||
|
|
@ -295,4 +305,102 @@ describe("ResourcePreviewPanel", () => {
|
|||
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("# not a heading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("URI-suffix MIME inference", () => {
|
||||
it("infers text/csv from a .csv URI and renders a table", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "data", uri: "file:///data.csv" }}
|
||||
contents={[{ uri: "file:///data.csv", text: "a,b\n1,2" }]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "a" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("text/csv")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("infers application/json from a .json URI and highlights", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "cfg", uri: "file:///cfg.json" }}
|
||||
contents={[{ uri: "file:///cfg.json", text: '{"a":1}' }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("code-highlight")).toHaveAttribute(
|
||||
"data-language",
|
||||
"json",
|
||||
);
|
||||
});
|
||||
|
||||
it("infers application/xml from a .xml URI and highlights", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "feed", uri: "file:///feed.xml" }}
|
||||
contents={[{ uri: "file:///feed.xml", text: "<a><b/></a>" }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("code-highlight")).toHaveAttribute(
|
||||
"data-language",
|
||||
"xml",
|
||||
);
|
||||
});
|
||||
|
||||
it("infers text/css from a .css URI and highlights", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "style", uri: "file:///style.css" }}
|
||||
contents={[{ uri: "file:///style.css", text: ".a{}" }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("code-highlight")).toHaveAttribute(
|
||||
"data-language",
|
||||
"css",
|
||||
);
|
||||
});
|
||||
|
||||
describe("blob-URL renderers", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:url");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("infers text/html from a .html URI and sandboxes it", () => {
|
||||
const { container } = renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "report", uri: "file:///report.html" }}
|
||||
contents={[{ uri: "file:///report.html", text: "<p>hi</p>" }]}
|
||||
/>,
|
||||
);
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).toHaveAttribute("sandbox", "");
|
||||
});
|
||||
|
||||
it("infers application/pdf from a .pdf URI and renders a viewer", () => {
|
||||
const { container } = renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "doc", uri: "file:///doc.pdf" }}
|
||||
contents={[
|
||||
{
|
||||
uri: "file:///doc.pdf",
|
||||
blob: Buffer.from("%PDF", "utf-8").toString("base64"),
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).toHaveAttribute("src", "blob:url#view=FitH");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
} from "@mantine/core";
|
||||
import type {
|
||||
BlobResourceContents,
|
||||
ContentBlock,
|
||||
Resource,
|
||||
TextResourceContents,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
|
@ -41,25 +40,6 @@ export interface ResourcePreviewPanelProps {
|
|||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function toContentBlock(
|
||||
item: TextResourceContents | BlobResourceContents,
|
||||
): ContentBlock {
|
||||
if ("text" in item) {
|
||||
return { type: "text", text: item.text };
|
||||
}
|
||||
const mimeType = item.mimeType ?? "application/octet-stream";
|
||||
if (mimeType.startsWith("image/")) {
|
||||
return { type: "image", data: item.blob, mimeType };
|
||||
}
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
return { type: "audio", data: item.blob, mimeType };
|
||||
}
|
||||
return {
|
||||
type: "text",
|
||||
text: `[Binary content (${mimeType}) — preview not supported]`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatLastUpdated(date: Date): string {
|
||||
return `Last updated: ${date.toLocaleString()}`;
|
||||
}
|
||||
|
|
@ -145,14 +125,31 @@ const ContentStack = Stack.withProps({
|
|||
gap: "md",
|
||||
});
|
||||
|
||||
// Infer a markdown MIME from the URI when the server didn't supply one.
|
||||
// MCP servers often return `text/plain` (or omit mimeType entirely) for
|
||||
// `.md` resources; the file extension is the most reliable fallback signal.
|
||||
// Map a file extension to the MIME type that drives ContentViewer's per-MIME
|
||||
// renderer dispatch. MCP servers commonly omit `mimeType` (or return a generic
|
||||
// `text/plain` / `application/octet-stream`), so the URI suffix is the most
|
||||
// reliable signal for engaging the markdown / PDF / CSV / XML / HTML / CSS
|
||||
// renderers. Order doesn't matter — suffixes are unique.
|
||||
const URI_SUFFIX_MIME: ReadonlyArray<readonly [string, string]> = [
|
||||
[".md", "text/markdown"],
|
||||
[".markdown", "text/markdown"],
|
||||
[".csv", "text/csv"],
|
||||
[".json", "application/json"],
|
||||
[".xml", "application/xml"],
|
||||
[".html", "text/html"],
|
||||
[".htm", "text/html"],
|
||||
[".css", "text/css"],
|
||||
[".pdf", "application/pdf"],
|
||||
];
|
||||
|
||||
// Infer a MIME type from the URI's file extension when the server didn't supply
|
||||
// one. Returns undefined for unrecognized suffixes so callers fall through to
|
||||
// the octet-stream default.
|
||||
function inferMimeFromUri(uri: string): string | undefined {
|
||||
const path = uri.split("?")[0].split("#")[0];
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
|
||||
return "text/markdown";
|
||||
for (const [suffix, mime] of URI_SUFFIX_MIME) {
|
||||
if (lower.endsWith(suffix)) return mime;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -202,7 +199,7 @@ export function ResourcePreviewPanel({
|
|||
{contents.map((item, index) => (
|
||||
<ContentViewer
|
||||
key={index}
|
||||
block={toContentBlock(item)}
|
||||
contents={item}
|
||||
mimeType={effectiveMime(item.mimeType, resource)}
|
||||
copyable
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue