feat(web): expand ContentViewer to render PDF, CSV, HTML, XML, JSON, CSS (closes #1329) (#1551)

* 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:
Cliff Hall 2026-06-30 09:18:43 -04:00 • committed by GitHub
commit baf79b843f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2193 additions and 110 deletions

View file

@ -26,11 +26,13 @@
"commander": "^13.1.0",
"hono": "^4.12.18",
"open": "^10.2.0",
"papaparse": "^5.5.4",
"pino": "^9.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"zod": "^4.3.6",
"zustand": "^5.0.13"
@ -51,8 +53,10 @@
"@testing-library/user-event": "^14.6.1",
"@types/express": "^5.0.6",
"@types/node": "^24.12.4",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^6.0.0",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",
@ -2979,12 +2983,28 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/papaparse": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@ -3018,6 +3038,16 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-syntax-highlighter": {
"version": "15.5.13",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
@ -5128,6 +5158,19 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -5246,6 +5289,14 @@
"dev": true,
"license": "ISC"
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -5499,6 +5550,19 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@ -5539,6 +5603,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@ -5556,6 +5637,21 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@ -6385,6 +6481,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"license": "MIT",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -7623,6 +7733,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/papaparse": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz",
"integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -8055,6 +8171,15 @@
"license": "MIT",
"peer": true
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@ -8334,6 +8459,26 @@
}
}
},
"node_modules/react-syntax-highlighter": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^5.0.0"
},
"engines": {
"node": ">= 16.20.2"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/react-textarea-autosize": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
@ -8443,6 +8588,22 @@
"node": ">=8"
}
},
"node_modules/refractor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/prismjs": "^1.0.0",
"hastscript": "^9.0.0",
"parse-entities": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",

View file

@ -50,11 +50,13 @@
"commander": "^13.1.0",
"hono": "^4.12.18",
"open": "^10.2.0",
"papaparse": "^5.5.4",
"pino": "^9.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"zod": "^4.3.6",
"zustand": "^5.0.13"
@ -72,8 +74,10 @@
"@testing-library/user-event": "^14.6.1",
"@types/express": "^5.0.6",
"@types/node": "^24.12.4",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^6.0.0",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",

View file

@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CodeHighlight } from "./CodeHighlight";
const meta: Meta<typeof CodeHighlight> = {
title: "Elements/CodeHighlight",
component: CodeHighlight,
};
export default meta;
type Story = StoryObj<typeof CodeHighlight>;
export const Json: Story = {
args: {
language: "json",
code: JSON.stringify(
{ name: "my-app", version: "1.0.0", tags: ["a", "b"] },
null,
2,
),
},
};
export const Xml: Story = {
args: {
language: "xml",
code: '<root>\n <item id="1">first</item>\n <item id="2">second</item>\n</root>',
},
};
export const Css: Story = {
args: {
language: "css",
code: ".card {\n color: var(--text);\n padding: 1rem;\n}",
},
};
export const UnknownLanguage: Story = {
args: {
language: "brainfuck",
code: "++++++++[>++++[>++>+++<<-]>+>->]",
},
};

View file

@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
renderWithMantine,
screen,
waitFor,
} from "../../../test/renderWithMantine";
// --- Mock the lazily-imported react-syntax-highlighter chunks -----------------
// The prism-light runtime: a component that renders its code into a probeable
// element, plus a `registerLanguage` spy so tests can assert load behavior.
const registerLanguage = vi.fn();
vi.mock("react-syntax-highlighter/dist/esm/prism-light", () => ({
default: Object.assign(
({ language, children }: { language: string; children: string }) => (
<pre data-testid="prism" data-language={language}>
{children}
</pre>
),
{ registerLanguage },
),
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/tomorrow", () => ({
default: {},
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/json", () => ({
default: { __grammar: "json" },
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/markup", () => ({
default: { __grammar: "markup" },
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/css", () => ({
default: { __grammar: "css" },
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/yaml", () => ({
default: { __grammar: "yaml" },
}));
// markdown's import rejects, exercising the failed-load path.
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/markdown", () => {
throw new Error("grammar load failed");
});
// Module-level caches in CodeHighlight must reset between tests; re-import fresh.
async function loadComponent() {
vi.resetModules();
registerLanguage.mockClear();
return (await import("./CodeHighlight")).CodeHighlight;
}
beforeEach(() => {
vi.resetModules();
});
describe("CodeHighlight", () => {
it("renders plain code initially, then upgrades to the highlighter", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="json" code='{"a":1}' />);
// Plain Mantine Code before the grammar resolves.
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(screen.getByText('{"a":1}')).toBeInTheDocument();
// After the lazy grammar loads, the prism runtime takes over.
const prism = await screen.findByTestId("prism");
expect(prism).toHaveAttribute("data-language", "json");
expect(registerLanguage).toHaveBeenCalledWith("json", {
__grammar: "json",
});
});
it("resolves the xml alias to the markup grammar", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="xml" code="<a/>" />);
const prism = await screen.findByTestId("prism");
expect(prism).toHaveAttribute("data-language", "markup");
expect(registerLanguage).toHaveBeenCalledWith("markup", {
__grammar: "markup",
});
});
it("stays plain for an unknown language and never imports a grammar", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="brainfuck" code="++." />);
// Give the effect a chance to run.
await waitFor(() => expect(screen.getByText("++.")).toBeInTheDocument());
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(registerLanguage).not.toHaveBeenCalled();
});
it("stays plain when a grammar import rejects", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="md" code="# hi" />);
await waitFor(() => expect(screen.getByText("# hi")).toBeInTheDocument());
// markdown's import throws → failed load → never registered, stays plain.
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(registerLanguage).not.toHaveBeenCalledWith(
"markdown",
expect.anything(),
);
});
it("shares one grammar load across concurrent mounts of the same language", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(
<>
<CodeHighlight language="css" code=".a{}" />
<CodeHighlight language="css" code=".b{}" />
</>,
);
await waitFor(() => expect(screen.getAllByTestId("prism")).toHaveLength(2));
// Both mounts shared the in-flight promise → grammar registered once.
expect(registerLanguage).toHaveBeenCalledTimes(1);
expect(registerLanguage).toHaveBeenCalledWith("css", { __grammar: "css" });
});
it("reuses an already-registered grammar without re-importing", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="json" code='{"a":1}' />,
);
await screen.findByTestId("prism");
expect(registerLanguage).toHaveBeenCalledTimes(1);
unmount();
// A second mount finds json already registered: ready synchronously, no
// second registerLanguage call.
renderWithMantine(<CodeHighlight language="json" code='{"b":2}' />);
expect(await screen.findByTestId("prism")).toBeInTheDocument();
expect(registerLanguage).toHaveBeenCalledTimes(1);
});
it("loads a second language reusing the already-loaded runtime", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="json" code='{"a":1}' />,
);
await screen.findByTestId("prism");
unmount();
// Runtime is cached now; mounting css registers only the new grammar.
renderWithMantine(<CodeHighlight language="css" code=".a{}" />);
const prism = await screen.findByTestId("prism");
expect(prism).toHaveAttribute("data-language", "css");
expect(registerLanguage).toHaveBeenCalledTimes(2);
expect(registerLanguage).toHaveBeenLastCalledWith("css", {
__grammar: "css",
});
});
it("shares one runtime load across two different languages mounted together", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(
<>
<CodeHighlight language="json" code='{"a":1}' />
<CodeHighlight language="css" code=".a{}" />
</>,
);
await waitFor(() => expect(screen.getAllByTestId("prism")).toHaveLength(2));
// Both grammars registered, but the shared runtime promise loaded once.
expect(registerLanguage).toHaveBeenCalledTimes(2);
});
it("does not retry a language whose grammar previously failed", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="md" code="# a" />,
);
await waitFor(() => expect(screen.getByText("# a")).toBeInTheDocument());
unmount();
// markdown is in failedLoads now; a second mount short-circuits and never
// attempts the import again — still plain, still no registration.
renderWithMantine(<CodeHighlight language="md" code="# b" />);
await waitFor(() => expect(screen.getByText("# b")).toBeInTheDocument());
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(registerLanguage).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,168 @@
import { Code } from "@mantine/core";
import { useEffect, useState } from "react";
import type { ComponentType } from "react";
/**
* Lazy syntax-highlighting code block. The `react-syntax-highlighter`
* prism-light runtime, its theme chunk, and each language grammar are
* dynamic-imported on first use so a session that never opens a highlightable
* resource never pays for them. While a grammar is still loading (or the
* language is unknown) the raw code is shown in a plain Mantine `Code` block so
* users never see a flash of unstyled tokens.
*/
export interface CodeHighlightProps {
/** Highlight.js / Prism language tag (or an alias — see {@link LANGUAGE_ALIASES}). */
language: string;
/** The source text to render. */
code: string;
}
/** A Prism grammar object; opaque to us — registered with the runtime as-is. */
type Grammar = unknown;
/** The prism-light runtime component plus its `registerLanguage` static. */
type PrismRuntime = ComponentType<{
language: string;
style: Record<string, unknown>;
customStyle?: Record<string, unknown>;
wrapLongLines?: boolean;
children: string;
}> & { registerLanguage: (name: string, grammar: Grammar) => void };
/**
* Static per-language import thunks. Each is a separate dynamic import so Vite
* emits one lazily-loaded chunk per grammar. Add an entry here (and an alias
* below if the canonical Prism name differs) as the type matrix grows.
*/
const LANGUAGE_LOADERS: Record<string, () => Promise<{ default: Grammar }>> = {
json: () => import("react-syntax-highlighter/dist/esm/languages/prism/json"),
markup: () =>
import("react-syntax-highlighter/dist/esm/languages/prism/markup"),
css: () => import("react-syntax-highlighter/dist/esm/languages/prism/css"),
yaml: () => import("react-syntax-highlighter/dist/esm/languages/prism/yaml"),
markdown: () =>
import("react-syntax-highlighter/dist/esm/languages/prism/markdown"),
};
/** Friendly language tags → the canonical Prism grammar name they resolve to. */
const LANGUAGE_ALIASES: Record<string, string> = {
xml: "markup",
html: "markup",
htm: "markup",
svg: "markup",
yml: "yaml",
md: "markdown",
};
/** Grammars successfully loaded + registered this session. */
const registeredLanguages = new Set<string>();
/** Grammars whose import rejected / are unknown — never retried. */
const failedLoads = new Set<string>();
/** In-flight grammar loads, so concurrent mounts share one async call. */
const loadingPromises = new Map<string, Promise<void>>();
/** The shared prism-light runtime component + theme. */
interface Runtime {
Prism: PrismRuntime;
style: Record<string, unknown>;
}
/** The shared prism-light runtime + theme, loaded once. */
let runtime: Runtime | null = null;
let runtimePromise: Promise<Runtime> | null = null;
/** Resolve an alias to its canonical Prism grammar name. */
function resolveLanguage(language: string): string {
return LANGUAGE_ALIASES[language] ?? language;
}
/** Load (and cache) the prism-light runtime component and the `tomorrow` theme. */
async function ensureRuntime(): Promise<Runtime> {
if (runtime) return runtime;
if (!runtimePromise) {
runtimePromise = (async () => {
const [prismMod, styleMod] = await Promise.all([
import("react-syntax-highlighter/dist/esm/prism-light"),
import("react-syntax-highlighter/dist/esm/styles/prism/tomorrow"),
]);
runtime = {
Prism: prismMod.default as PrismRuntime,
style: styleMod.default,
};
return runtime;
})();
}
return runtimePromise;
}
/**
* Ensure the grammar for `language` is loaded and registered. Resolves once the
* language is ready, a prior load failed, or the language is unknown — callers
* re-check {@link isLanguageReady} afterward rather than relying on this throwing.
*/
async function ensureLanguage(language: string): Promise<void> {
const name = resolveLanguage(language);
if (registeredLanguages.has(name) || failedLoads.has(name)) return;
const inFlight = loadingPromises.get(name);
if (inFlight) return inFlight;
const loader = LANGUAGE_LOADERS[name];
if (!loader) {
failedLoads.add(name);
return;
}
const load = (async () => {
try {
const rt = await ensureRuntime();
const { default: grammar } = await loader();
rt.Prism.registerLanguage(name, grammar);
registeredLanguages.add(name);
} catch {
failedLoads.add(name);
} finally {
loadingPromises.delete(name);
}
})();
loadingPromises.set(name, load);
return load;
}
const PlainCode = Code.withProps({ block: true });
export function CodeHighlight({ language, code }: CodeHighlightProps) {
// Readiness is derived from the module-level caches during render (so a
// language already loaded this session highlights on first paint, including
// after the `language` prop changes). The effect only bumps a tick when an
// async load finishes, forcing a re-render that re-reads the caches.
const [, bumpTick] = useState(0);
useEffect(() => {
let cancelled = false;
void ensureLanguage(language).then(() => {
if (!cancelled) bumpTick((t) => t + 1);
});
return () => {
cancelled = true;
};
}, [language]);
const resolved = resolveLanguage(language);
const rt = runtime;
// Plain block until the runtime has loaded and this grammar is registered.
if (!rt || !registeredLanguages.has(resolved)) {
return <PlainCode>{code}</PlainCode>;
}
const { Prism, style } = rt;
return (
<Prism
language={resolved}
style={style}
customStyle={{ margin: 0 }}
wrapLongLines
>
{code}
</Prism>
);
}

View file

@ -0,0 +1,24 @@
import { Code, Flex, Stack } from "@mantine/core";
const ContentWrapper = Flex.withProps({
pos: "relative",
direction: "column",
});
/**
* Fallback shown when content can't be previewed — an unsupported binary MIME
* type, or a blob whose base64 fails to decode. Kept in its own module so blob
* renderers (e.g. {@link PdfFrame}) can degrade to it without importing back
* into {@link ContentViewer} (which would form an import cycle).
*/
export function BinaryNotice({ mimeType }: { mimeType: string }) {
return (
<Stack gap="xs">
<ContentWrapper>
<Code block p={36}>
{`[Binary content (${mimeType}) — preview not supported]`}
</Code>
</ContentWrapper>
</Stack>
);
}

View file

@ -86,3 +86,68 @@ export const ResourceLinkBlock: Story = {
},
},
};
// --- Resource contents (Resources screen) per-MIME dispatch -------------------
export const JsonHighlighted: Story = {
args: {
contents: {
uri: "file:///config.json",
mimeType: "application/json",
text: '{"name":"my-app","version":"1.0.0","tags":["a","b"]}',
},
copyable: true,
},
};
export const XmlHighlighted: Story = {
args: {
contents: {
uri: "file:///feed.xml",
mimeType: "application/xml",
text: "<rss><channel><title>News</title><item>One</item></channel></rss>",
},
copyable: true,
},
};
export const CssHighlighted: Story = {
args: {
contents: {
uri: "file:///styles.css",
mimeType: "text/css",
text: ".card { color: var(--text); padding: 1rem; border-radius: 8px; }",
},
copyable: true,
},
};
export const CsvTableContents: Story = {
args: {
contents: {
uri: "file:///people.csv",
mimeType: "text/csv",
text: "name,role,city\nAlice,Engineer,Berlin\nBob,Designer,Lisbon\nCarol,PM,Oslo",
},
},
};
export const HtmlSandboxed: Story = {
args: {
contents: {
uri: "file:///report.html",
mimeType: "text/html",
text: "<h1>Report</h1><p>This renders in a <strong>sandboxed</strong> iframe.</p>",
},
},
};
export const BinaryUnsupported: Story = {
args: {
contents: {
uri: "file:///archive.zip",
mimeType: "application/zip",
blob: "UEsDBAoAAAAAAA==",
},
},
};

View file

@ -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");
});
});
});

View file

@ -1,17 +1,48 @@
import { Code, Flex, Image, Stack } from "@mantine/core";
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import type { ReactNode } from "react";
import type {
BlobResourceContents,
ContentBlock,
TextResourceContents,
} from "@modelcontextprotocol/sdk/types.js";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { CodeHighlight } from "../CodeHighlight/CodeHighlight";
import { CopyButton } from "../CopyButton/CopyButton";
import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo";
import {
formatJson,
formatXml,
getMimeKind,
isSafeHref,
isTextualKind,
looksLikeJson,
tryDecodeBase64ToUtf8,
} from "./contentViewerUtils";
import { BinaryNotice } from "./BinaryNotice";
import { CsvTable } from "./CsvTable";
import { HtmlFrame } from "./HtmlFrame";
import { PdfFrame } from "./PdfFrame";
export interface ContentViewerProps {
block: ContentBlock;
/**
* A content block to render (tool results, prompt messages, server cards, …).
* Provide either `block` or `contents`.
*/
block?: ContentBlock;
/**
* Raw resource contents (Resources screen). When provided, the per-MIME
* dispatch keys off `mimeType` and the base64 `blob` / `text`, covering
* PDF / CSV / HTML / XML / CSS in addition to the content-block cases.
* Provide either `block` or `contents`.
*/
contents?: TextResourceContents | BlobResourceContents;
copyable?: boolean;
/**
* Optional MIME type for the block. When `text/markdown` (or
* `text/x-markdown`), text content is rendered via react-markdown
* instead of as preformatted code.
* Effective MIME type for the content. Drives the per-MIME renderer dispatch
* (markdown, JSON, XML, CSS, CSV, HTML, PDF). When absent, text falls back to
* a JSON-shape heuristic then plain preformatted code.
*/
mimeType?: string;
/**
@ -29,26 +60,6 @@ export interface ContentViewerProps {
wrap?: boolean;
}
function formatJson(content: string): string {
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}
function isJsonText(block: ContentBlock): boolean {
if (block.type !== "text") return false;
const trimmed = block.text.trimStart();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
function isMarkdownMime(mimeType: string | undefined): boolean {
if (!mimeType) return false;
const base = mimeType.split(";")[0].trim().toLowerCase();
return base === "text/markdown" || base === "text/x-markdown";
}
function buildDataUri(mimeType: string, data: string): string {
return `data:${mimeType};base64,${data}`;
}
@ -75,71 +86,264 @@ const PreviewImage = Image.withProps({
radius: "md",
});
export function ContentViewer({
block,
copyable = false,
// Markdown anchors are constrained to a safe-scheme allowlist: a non-matching
// href (e.g. `javascript:`, protocol-relative `//evil.com`) renders as inert
// text so user-supplied markdown can't smuggle a script-bearing link.
const SafeAnchor: Components["a"] = ({ href, children }) =>
isSafeHref(href) ? <a href={href}>{children}</a> : <span>{children}</span>;
const markdownComponents: Components = { a: SafeAnchor };
function CopyableWrapper({
copyable,
copyValue,
children,
}: {
copyable: boolean;
copyValue: string;
children: ReactNode;
}) {
return (
<Stack gap="xs">
<ContentWrapper>
{children}
{copyable && (
<CopyOverlay>
<CopyButton value={copyValue} />
</CopyOverlay>
)}
</ContentWrapper>
</Stack>
);
}
function MarkdownContent({
text,
copyable,
}: {
text: string;
copyable: boolean;
}) {
return (
<CopyableWrapper copyable={copyable} copyValue={text}>
<MarkdownWrapper>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{text}
</ReactMarkdown>
</MarkdownWrapper>
</CopyableWrapper>
);
}
function HighlightedContent({
code,
language,
copyValue,
copyable,
}: {
code: string;
language: string;
copyValue: string;
copyable: boolean;
}) {
return (
<CopyableWrapper copyable={copyable} copyValue={copyValue}>
<CodeHighlight language={language} code={code} />
</CopyableWrapper>
);
}
function PlainTextContent({
text,
copyable,
wrap,
}: {
text: string;
copyable: boolean;
wrap: boolean;
}) {
const displayText = looksLikeJson(text) ? formatJson(text) : text;
return (
<CopyableWrapper copyable={copyable} copyValue={text}>
<Code
block
p={36}
variant={wrap ? "wrapping" : "nowrap"}
// When not wrapping, the value may be clipped with an ellipsis; expose
// the full text on hover so it's readable without copying.
title={wrap ? undefined : displayText}
>
{displayText}
</Code>
</CopyableWrapper>
);
}
/**
* Render decoded text according to its MIME type: markdown, syntax-highlighted
* JSON / XML / CSS, a CSV table, a sandboxed HTML iframe, or — for plain or
* unrecognized text — a preformatted code block (with a JSON-shape heuristic so
* mimeless JSON still pretty-prints).
*/
function TextualContent({
text,
mimeType,
wrap = true,
}: ContentViewerProps) {
switch (block.type) {
case "text": {
const renderAsMarkdown = isMarkdownMime(mimeType);
if (renderAsMarkdown) {
return (
<Stack gap="xs">
<ContentWrapper>
<MarkdownWrapper>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{block.text}
</ReactMarkdown>
</MarkdownWrapper>
{copyable && (
<CopyOverlay>
<CopyButton value={block.text} />
</CopyOverlay>
)}
</ContentWrapper>
</Stack>
);
}
const isJson = isJsonText(block);
const displayText = isJson ? formatJson(block.text) : block.text;
copyable,
wrap,
}: {
text: string;
mimeType: string | undefined;
copyable: boolean;
wrap: boolean;
}) {
const kind = mimeType ? getMimeKind(mimeType) : "text";
switch (kind) {
case "markdown":
return <MarkdownContent text={text} copyable={copyable} />;
case "json":
return (
<HighlightedContent
code={formatJson(text)}
language="json"
copyValue={text}
copyable={copyable}
/>
);
case "xml":
return (
<HighlightedContent
code={formatXml(text)}
language="xml"
copyValue={text}
copyable={copyable}
/>
);
case "css":
return (
<HighlightedContent
code={text}
language="css"
copyValue={text}
copyable={copyable}
/>
);
case "csv":
return (
<Stack gap="xs">
<ContentWrapper>
<Code
block
p={36}
variant={wrap ? "wrapping" : "nowrap"}
// When not wrapping, the value may be clipped with an ellipsis;
// expose the full text on hover so it's readable without copying.
title={wrap ? undefined : displayText}
>
{displayText}
</Code>
{copyable && (
<CopyOverlay>
<CopyButton value={block.text} />
</CopyOverlay>
)}
</ContentWrapper>
<CsvTable text={text} />
</Stack>
);
case "html":
return (
<Stack gap="xs">
<HtmlFrame html={text} />
</Stack>
);
default:
return <PlainTextContent text={text} copyable={copyable} wrap={wrap} />;
}
}
function ImageContent({ data, mimeType }: { data: string; mimeType: string }) {
return (
<Stack gap="xs">
<PreviewImage src={buildDataUri(mimeType, data)} />
</Stack>
);
}
function AudioContent({ data, mimeType }: { data: string; mimeType: string }) {
return (
<Stack gap="xs">
<audio controls>
<source src={buildDataUri(mimeType, data)} />
</audio>
</Stack>
);
}
/** Dispatch raw resource contents (Resources screen) on their effective MIME. */
function ResourceContent({
contents,
mimeType,
copyable,
wrap,
}: {
contents: TextResourceContents | BlobResourceContents;
mimeType: string;
copyable: boolean;
wrap: boolean;
}) {
if ("text" in contents) {
return (
<TextualContent
text={contents.text}
mimeType={mimeType}
copyable={copyable}
wrap={wrap}
/>
);
}
const kind = getMimeKind(mimeType);
if (kind === "image") {
return <ImageContent data={contents.blob} mimeType={mimeType} />;
}
if (kind === "audio") {
return <AudioContent data={contents.blob} mimeType={mimeType} />;
}
if (kind === "pdf") {
return (
<Stack gap="xs">
<PdfFrame data={contents.blob} />
</Stack>
);
}
if (isTextualKind(kind)) {
const decoded = tryDecodeBase64ToUtf8(contents.blob);
if (decoded === null) {
return <BinaryNotice mimeType={mimeType} />;
}
return (
<TextualContent
text={decoded}
mimeType={mimeType}
copyable={copyable}
wrap={wrap}
/>
);
}
return <BinaryNotice mimeType={mimeType} />;
}
/** Dispatch a content block (tool results, prompt messages, …) on its type. */
function BlockContent({
block,
mimeType,
copyable,
wrap,
}: {
block: ContentBlock;
mimeType: string | undefined;
copyable: boolean;
wrap: boolean;
}) {
switch (block.type) {
case "text":
return (
<TextualContent
text={block.text}
mimeType={mimeType}
copyable={copyable}
wrap={wrap}
/>
);
case "image":
return (
<Stack gap="xs">
<PreviewImage src={buildDataUri(block.mimeType, block.data)} />
</Stack>
);
return <ImageContent data={block.data} mimeType={block.mimeType} />;
case "audio":
return (
<Stack gap="xs">
<audio controls>
<source src={buildDataUri(block.mimeType, block.data)} />
</audio>
</Stack>
);
return <AudioContent data={block.data} mimeType={block.mimeType} />;
case "resource":
return (
<Stack gap="xs">
@ -168,3 +372,33 @@ export function ContentViewer({
return null;
}
}
export function ContentViewer({
block,
contents,
copyable = false,
mimeType,
wrap = true,
}: ContentViewerProps) {
if (contents) {
const effective =
mimeType ?? contents.mimeType ?? "application/octet-stream";
return (
<ResourceContent
contents={contents}
mimeType={effective}
copyable={copyable}
wrap={wrap}
/>
);
}
if (!block) return null;
return (
<BlockContent
block={block}
mimeType={mimeType}
copyable={copyable}
wrap={wrap}
/>
);
}

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { CsvTable, MAX_ROWS } from "./CsvTable";
describe("CsvTable", () => {
it("renders a table with headers and rows", () => {
const csv = "name,age\nAlice,30\nBob,25";
renderWithMantine(<CsvTable text={csv} />);
expect(
screen.getByRole("columnheader", { name: "name" }),
).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "age" }),
).toBeInTheDocument();
expect(screen.getByRole("cell", { name: "Alice" })).toBeInTheDocument();
expect(screen.getByRole("cell", { name: "25" })).toBeInTheDocument();
});
it("fills missing cells with empty strings", () => {
const csv = "a,b,c\n1,2";
const { container } = renderWithMantine(<CsvTable text={csv} />);
const cells = container.querySelectorAll("tbody td");
expect(cells).toHaveLength(3);
expect(cells[2].textContent).toBe("");
});
it("caps rendered rows at MAX_ROWS and surfaces the truncation", () => {
const total = MAX_ROWS + 50;
const rows = Array.from({ length: total }, (_, i) => `r${i},${i}`);
const csv = `name,n\n${rows.join("\n")}`;
const { container } = renderWithMantine(<CsvTable text={csv} />);
expect(container.querySelectorAll("tbody tr")).toHaveLength(MAX_ROWS);
expect(
screen.getByText(`Showing first ${MAX_ROWS} of ${total} rows`),
).toBeInTheDocument();
});
it("shows no truncation caption when all rows fit", () => {
const csv = "name,age\nAlice,30\nBob,25";
const { container } = renderWithMantine(<CsvTable text={csv} />);
expect(container.querySelector("caption")).toBeNull();
});
it("falls back to a plain code block when there are no columns", () => {
const { container } = renderWithMantine(<CsvTable text="" />);
expect(container.querySelector("table")).toBeNull();
expect(container.querySelector(".mantine-Code-root")).toBeInTheDocument();
});
it("falls back to a plain code block when there are no data rows", () => {
// Header only, no data rows → not a useful table.
const { container } = renderWithMantine(<CsvTable text="only_header" />);
expect(container.querySelector("table")).toBeNull();
expect(screen.getByText("only_header")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,76 @@
import { Code, Table } from "@mantine/core";
import { useMemo } from "react";
import Papa from "papaparse";
/**
* Render CSV text as a Mantine `Table`. Parsed with papaparse in header mode;
* only the first {@link MAX_ROWS} rows are shown to keep large files cheap. When
* the text doesn't parse as a header-bearing table (no detected columns), the
* raw text is shown in a plain wrapping `Code` block instead of throwing.
*/
export interface CsvTableProps {
/** The CSV document text. */
text: string;
}
/** Cap rendered rows so a huge CSV doesn't mount thousands of DOM nodes. */
export const MAX_ROWS = 100;
const PlainCode = Code.withProps({ block: true, variant: "wrapping" });
interface ParsedCsv {
fields: string[];
rows: string[][];
/** Total parsed row count, before the {@link MAX_ROWS} display cap. */
total: number;
}
function parseCsv(text: string): ParsedCsv | null {
const result = Papa.parse<Record<string, string>>(text, {
header: true,
skipEmptyLines: true,
});
const fields = result.meta.fields ?? [];
if (fields.length === 0 || result.data.length === 0) {
return null;
}
const rows = result.data
.slice(0, MAX_ROWS)
.map((row) => fields.map((field) => row[field] ?? ""));
return { fields, rows, total: result.data.length };
}
export function CsvTable({ text }: CsvTableProps) {
const parsed = useMemo(() => parseCsv(text), [text]);
if (!parsed) {
return <PlainCode>{text}</PlainCode>;
}
const truncated = parsed.total > MAX_ROWS;
return (
<Table striped highlightOnHover withTableBorder withColumnBorders>
{truncated && (
<Table.Caption>
{`Showing first ${MAX_ROWS} of ${parsed.total} rows`}
</Table.Caption>
)}
<Table.Thead>
<Table.Tr>
{parsed.fields.map((field) => (
<Table.Th key={field}>{field}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{parsed.rows.map((row, rowIndex) => (
<Table.Tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<Table.Td key={cellIndex}>{cell}</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}

View file

@ -0,0 +1,47 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { HtmlFrame } from "./HtmlFrame";
describe("HtmlFrame", () => {
let capturedBlob: Blob | undefined;
beforeEach(() => {
capturedBlob = undefined;
vi.spyOn(URL, "createObjectURL").mockImplementation((blob) => {
capturedBlob = blob as Blob;
return "blob:html-url";
});
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a sandboxed iframe over a blob URL", () => {
const { container } = renderWithMantine(<HtmlFrame html="<p>hello</p>" />);
const iframe = container.querySelector("iframe");
expect(iframe).toHaveAttribute("src", "blob:html-url");
// Explicitly-empty sandbox: no scripts, forms, or same-origin.
expect(iframe).toHaveAttribute("sandbox", "");
expect(iframe).toHaveAttribute("title", "HTML preview");
});
it("serves an HTML blob with the CSP meta injected", async () => {
renderWithMantine(<HtmlFrame html="<p>hello</p>" />);
expect(capturedBlob?.type).toBe("text/html");
const text = await capturedBlob!.text();
expect(text).toContain('http-equiv="Content-Security-Policy"');
expect(text).toContain("default-src 'none'");
expect(text).not.toContain("script-src");
expect(text).toContain("<p>hello</p>");
});
it("revokes the blob URL on unmount", async () => {
const { unmount } = renderWithMantine(<HtmlFrame html="<p>x</p>" />);
unmount();
// Revocation is deferred to a microtask (StrictMode-safe); let it drain.
await Promise.resolve();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:html-url");
});
});

View file

@ -0,0 +1,39 @@
import { Box } from "@mantine/core";
import { useMemo } from "react";
import { wrapHtmlWithCsp } from "./contentViewerUtils";
import { useObjectUrl } from "./useObjectUrl";
/**
* Render an HTML resource inside a hardened iframe. Defense is layered:
*
* - `sandbox=""` — explicitly empty: no `allow-scripts`, `allow-forms`, or
* `allow-same-origin`, so scripts can't run and the frame is origin-isolated.
* - A `Content-Security-Policy` `<meta>` is injected (see {@link wrapHtmlWithCsp})
* as defense-in-depth — correct even if the sandbox is later loosened.
* - The document is served from a `Blob` object URL (revoked on unmount) rather
* than `srcdoc`, keeping it off the parent's origin.
*/
export interface HtmlFrameProps {
/** The raw HTML document or fragment to preview. */
html: string;
}
export function HtmlFrame({ html }: HtmlFrameProps) {
const blob = useMemo(
() => new Blob([wrapHtmlWithCsp(html)], { type: "text/html" }),
[html],
);
const url = useObjectUrl(blob);
return (
<Box
component="iframe"
title="HTML preview"
src={url}
sandbox=""
w="100%"
h={400}
bd={0}
display="block"
/>
);
}

View file

@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { PdfFrame } from "./PdfFrame";
const PDF_BASE64 = Buffer.from("%PDF-1.4 fake", "utf-8").toString("base64");
describe("PdfFrame", () => {
beforeEach(() => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:pdf-url");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders an iframe over a blob URL with a fit-width hash", () => {
const { container } = renderWithMantine(<PdfFrame data={PDF_BASE64} />);
const iframe = container.querySelector("iframe");
expect(iframe).toHaveAttribute("src", "blob:pdf-url#view=FitH");
expect(iframe).toHaveAttribute("title", "PDF preview");
// The blob is built with the PDF MIME type.
const blobArg = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob;
expect(blobArg.type).toBe("application/pdf");
});
it("revokes the blob URL on unmount", async () => {
const { unmount } = renderWithMantine(<PdfFrame data={PDF_BASE64} />);
unmount();
// Revocation is deferred to a microtask (StrictMode-safe); let it drain.
await Promise.resolve();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:pdf-url");
});
it("degrades to the binary notice when the base64 is malformed", () => {
// `atob` throws on this; the frame must fall back instead of crashing.
const { container } = renderWithMantine(<PdfFrame data="not%%base64" />);
expect(container.querySelector("iframe")).toBeNull();
expect(container.textContent).toContain("preview not supported");
});
});

View file

@ -0,0 +1,43 @@
import { Box } from "@mantine/core";
import { useMemo } from "react";
import { BinaryNotice } from "./BinaryNotice";
import { tryDecodeBase64ToBytes } from "./contentViewerUtils";
import { useObjectUrl } from "./useObjectUrl";
/**
* Render a base64-encoded PDF in an in-page viewer. The bytes are wrapped in a
* `Blob` and served via an object URL (revoked on unmount / when the data
* changes) rather than a multi-megabyte `data:` URI. `#view=FitH` asks the
* browser's built-in viewer to fit the page width.
*
* The `blob` comes from an external MCP server; if its base64 fails to decode
* we degrade to the binary-content notice instead of throwing during render.
*/
export interface PdfFrameProps {
/** Base64-encoded PDF bytes (the `blob` field of a `BlobResourceContents`). */
data: string;
}
export function PdfFrame({ data }: PdfFrameProps) {
const bytes = useMemo(() => tryDecodeBase64ToBytes(data), [data]);
const blob = useMemo(
() =>
bytes ? new Blob([bytes], { type: "application/pdf" }) : new Blob([]),
[bytes],
);
const url = useObjectUrl(blob);
if (!bytes) {
return <BinaryNotice mimeType="application/pdf" />;
}
return (
<Box
component="iframe"
title="PDF preview"
src={`${url}#view=FitH`}
w="100%"
h={600}
bd={0}
display="block"
/>
);
}

View file

@ -0,0 +1,189 @@
import { describe, it, expect } from "vitest";
import {
decodeBase64ToBytes,
decodeBase64ToUtf8,
formatJson,
formatXml,
getMimeKind,
isSafeHref,
isTextualKind,
looksLikeJson,
PREVIEW_HTML_CSP,
tryDecodeBase64ToBytes,
tryDecodeBase64ToUtf8,
wrapHtmlWithCsp,
} from "./contentViewerUtils";
const toBase64 = (text: string): string =>
Buffer.from(text, "utf-8").toString("base64");
describe("getMimeKind", () => {
it.each([
["image/png", "image"],
["image/svg+xml", "image"],
["audio/mp3", "audio"],
["application/pdf", "pdf"],
["text/markdown", "markdown"],
["text/x-markdown", "markdown"],
["application/json", "json"],
["application/vnd.api+json", "json"],
["text/csv", "csv"],
["text/html", "html"],
["text/css", "css"],
["text/xml", "xml"],
["application/xml", "xml"],
["application/atom+xml", "xml"],
["application/javascript", "text"],
["application/ecmascript", "text"],
["application/x-javascript", "text"],
["text/plain", "text"],
["application/octet-stream", "binary"],
["application/zip", "binary"],
])("classifies %s as %s", (mime, expected) => {
expect(getMimeKind(mime)).toBe(expected);
});
it("ignores charset parameters and casing", () => {
expect(getMimeKind("TEXT/CSV; charset=utf-8")).toBe("csv");
});
});
describe("isTextualKind", () => {
it("is true for text families, false for binary media", () => {
expect(isTextualKind("json")).toBe(true);
expect(isTextualKind("csv")).toBe(true);
expect(isTextualKind("text")).toBe(true);
expect(isTextualKind("image")).toBe(false);
expect(isTextualKind("pdf")).toBe(false);
expect(isTextualKind("binary")).toBe(false);
});
});
describe("decodeBase64ToUtf8", () => {
it("round-trips UTF-8 text including multibyte characters", () => {
expect(decodeBase64ToUtf8(toBase64("héllo, 世界"))).toBe("héllo, 世界");
});
});
describe("decodeBase64ToBytes", () => {
it("decodes to the original byte sequence", () => {
const bytes = decodeBase64ToBytes(toBase64("AB"));
expect(Array.from(bytes)).toEqual([65, 66]);
});
});
describe("tryDecodeBase64ToUtf8", () => {
it("decodes valid base64 like the throwing variant", () => {
expect(tryDecodeBase64ToUtf8(toBase64("héllo"))).toBe("héllo");
});
it("returns null instead of throwing on malformed base64", () => {
expect(tryDecodeBase64ToUtf8("not%%base64")).toBeNull();
});
});
describe("tryDecodeBase64ToBytes", () => {
it("decodes valid base64 to bytes", () => {
expect(Array.from(tryDecodeBase64ToBytes(toBase64("AB"))!)).toEqual([
65, 66,
]);
});
it("returns null instead of throwing on malformed base64", () => {
expect(tryDecodeBase64ToBytes("not%%base64")).toBeNull();
});
});
describe("formatJson", () => {
it("pretty-prints valid JSON", () => {
expect(formatJson('{"a":1}')).toBe('{\n "a": 1\n}');
});
it("returns the input unchanged when invalid", () => {
expect(formatJson("{ broken")).toBe("{ broken");
});
});
describe("looksLikeJson", () => {
it("detects objects and arrays after leading whitespace", () => {
expect(looksLikeJson(' {"a":1}')).toBe(true);
expect(looksLikeJson("\n[1,2]")).toBe(true);
expect(looksLikeJson("plain")).toBe(false);
});
});
describe("formatXml", () => {
it("indents nested elements", () => {
const out = formatXml("<a><b>x</b></a>");
expect(out).toBe("<a>\n <b>x</b>\n</a>");
});
it("keeps self-closing tags at the same depth", () => {
const out = formatXml("<root><item/><item/></root>");
expect(out).toBe("<root>\n <item/>\n <item/>\n</root>");
});
it("collapses existing whitespace between tags", () => {
const out = formatXml("<a>\n <b>x</b>\n</a>");
expect(out).toBe("<a>\n <b>x</b>\n</a>");
});
it("never indents below zero on stray closing tags", () => {
const out = formatXml("</a></b>");
expect(out).toBe("</a>\n</b>");
});
});
describe("wrapHtmlWithCsp", () => {
it("injects the CSP meta at the top of an existing <head>", () => {
const out = wrapHtmlWithCsp(
"<html><head><title>t</title></head><body>x</body></html>",
);
expect(out).toContain(`<head><meta http-equiv="Content-Security-Policy"`);
expect(out.indexOf("Content-Security-Policy")).toBeLessThan(
out.indexOf("<title>"),
);
});
it("adds a <head> when the document has <html> but no head", () => {
const out = wrapHtmlWithCsp("<html><body>x</body></html>");
expect(out).toContain("<head><meta http-equiv");
expect(out).toContain("<body>x</body>");
});
it("wraps a bare fragment in a minimal document", () => {
const out = wrapHtmlWithCsp("<p>hi</p>");
expect(out).toBe(
`<html><head><meta http-equiv="Content-Security-Policy" content="${PREVIEW_HTML_CSP}"></head><body><p>hi</p></body></html>`,
);
});
it("preserves attributes on <head>/<html>", () => {
const out = wrapHtmlWithCsp('<head lang="en"></head>');
expect(out).toContain('<head lang="en"><meta http-equiv');
});
it("omits script-src so it falls through to default-src none", () => {
expect(PREVIEW_HTML_CSP).not.toContain("script-src");
expect(PREVIEW_HTML_CSP).toContain("default-src 'none'");
});
});
describe("isSafeHref", () => {
it.each([
"https://x.com",
"http://x.com",
"mailto:a@b.c",
"#anchor",
"/path",
])("allows %s", (href) => {
expect(isSafeHref(href)).toBe(true);
});
it.each(["javascript:alert(1)", "//evil.com", "data:text/html,x", undefined])(
"rejects %s",
(href) => {
expect(isSafeHref(href)).toBe(false);
},
);
});

View file

@ -0,0 +1,219 @@
/**
* Pure helpers backing the per-MIME dispatch in {@link ContentViewer}. Kept in
* a dependency-free module so they can be unit-tested in isolation and reused by
* the blob renderers (PDF / CSV / HTML) without dragging in React.
*/
/**
* The renderer family a MIME type maps to. `ContentViewer` switches on this to
* pick a branch; `binary` is the catch-all "preview not supported" fallback.
*/
export type MimeKind =
| "image"
| "audio"
| "pdf"
| "markdown"
| "json"
| "xml"
| "css"
| "csv"
| "html"
| "text"
| "binary";
/** Strip any `; charset=…` parameters and normalise case for comparison. */
function baseMime(mimeType: string): string {
return mimeType.split(";")[0].trim().toLowerCase();
}
/**
* Classify a MIME type into the renderer family `ContentViewer` should use.
* Structured-suffix types (`application/foo+json`, `image/svg+xml`) fold into
* their base family. Unknown `application/*` types fall through to `binary`.
*/
export function getMimeKind(mimeType: string): MimeKind {
const base = baseMime(mimeType);
if (base.startsWith("image/")) return "image";
if (base.startsWith("audio/")) return "audio";
if (base === "application/pdf") return "pdf";
if (base === "text/markdown" || base === "text/x-markdown") return "markdown";
if (base === "application/json" || base.endsWith("+json")) return "json";
if (base === "text/csv") return "csv";
if (base === "text/html") return "html";
if (base === "text/css") return "css";
if (
base === "text/xml" ||
base === "application/xml" ||
base.endsWith("+xml")
)
return "xml";
if (
base === "application/javascript" ||
base === "application/ecmascript" ||
base === "application/x-javascript"
)
return "text";
if (base.startsWith("text/")) return "text";
return "binary";
}
/** Renderer families that operate on decoded text rather than raw bytes. */
const TEXTUAL_KINDS: ReadonlySet<MimeKind> = new Set<MimeKind>([
"markdown",
"json",
"xml",
"css",
"csv",
"html",
"text",
]);
/** Whether a MIME kind is rendered from decoded UTF-8 text. */
export function isTextualKind(kind: MimeKind): boolean {
return TEXTUAL_KINDS.has(kind);
}
/**
* Decode a base64 string to UTF-8 text. Used when a server delivers inherently
* textual content (CSV, XML, HTML, …) as a `BlobResourceContents` blob instead
* of as `text`.
*/
export function decodeBase64ToUtf8(base64: string): string {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder("utf-8").decode(bytes);
}
/**
* Decode a base64 string to raw bytes. Used to build a `Blob` URL for binary
* previews (e.g. PDF) without round-tripping through a `data:` URI.
*/
export function decodeBase64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Decode base64 to UTF-8 text, returning `null` instead of throwing when the
* input isn't valid base64. `atob` raises `InvalidCharacterError` on malformed
* input; since the `blob` comes from an external MCP server and is decoded
* during render, a throw would take down the whole preview panel rather than
* degrading to the binary-content fallback. Callers treat `null` as "not
* decodable — show the fallback".
*/
export function tryDecodeBase64ToUtf8(base64: string): string | null {
try {
return decodeBase64ToUtf8(base64);
} catch {
return null;
}
}
/** Like {@link decodeBase64ToBytes} but returns `null` on malformed base64. */
export function tryDecodeBase64ToBytes(
base64: string,
): Uint8Array<ArrayBuffer> | null {
try {
return decodeBase64ToBytes(base64);
} catch {
return null;
}
}
/** Pretty-print JSON text; returns the input unchanged when it doesn't parse. */
export function formatJson(content: string): string {
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}
/** Heuristic: does this plain text (no MIME) look like a JSON document? */
export function looksLikeJson(text: string): boolean {
const trimmed = text.trimStart();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
/**
* Indent a single-line or minified XML/HTML-ish document for readability before
* syntax highlighting. Hand-rolled: split on `>\s*<` boundaries, then track a
* nesting depth, decrementing on closing tags and incrementing after opening
* tags that aren't self-closing or a one-line `<a>text</a>` pair.
*/
export function formatXml(xml: string): string {
const withBreaks = xml.replace(/>\s*</g, ">\n<").trim();
let depth = 0;
const out: string[] = [];
for (const raw of withBreaks.split("\n")) {
const line = raw.trim();
if (!line) continue;
const isClosing = /^<\//.test(line);
if (isClosing) depth = Math.max(depth - 1, 0);
out.push(" ".repeat(depth) + line);
const isOpening =
/^<[A-Za-z]/.test(line) && // a tag, not a comment / declaration
!/\/>$/.test(line) && // not self-closing
!isClosing && // not a closing tag
!/^<([A-Za-z][\w-]*)\b[^>]*>.*<\/\1>$/.test(line); // not a one-line pair
if (isOpening) depth++;
}
return out.join("\n");
}
/**
* Content-Security-Policy applied to previewed HTML resources. `script-src` is
* deliberately omitted so it falls through to `default-src 'none'` — that's what
* keeps the policy load-bearing if the iframe `sandbox` is ever loosened to
* allow scripts. Styles/fonts/images are permitted so reports render, but no
* navigation, plugins, or form submission.
*/
export const PREVIEW_HTML_CSP =
"default-src 'none'; " +
"style-src 'unsafe-inline' https://fonts.googleapis.com; " +
"img-src data: blob:; " +
"font-src data: https://fonts.gstatic.com; " +
"base-uri 'none'; " +
"object-src 'none'; " +
"form-action 'none';";
const CSP_META_TAG = `<meta http-equiv="Content-Security-Policy" content="${PREVIEW_HTML_CSP}">`;
/**
* Inject the preview CSP `<meta>` into an HTML document before it's served to a
* sandboxed iframe. Handles three shapes: a full document with a `<head>` (inject
* at the top of head), a document with `<html>` but no `<head>` (add a head), and
* a bare fragment (wrap in a minimal document).
*/
export function wrapHtmlWithCsp(html: string): string {
if (/<head[\s>]/i.test(html)) {
return html.replace(/<head([^>]*)>/i, `<head$1>${CSP_META_TAG}`);
}
if (/<html[\s>]/i.test(html)) {
return html.replace(
/<html([^>]*)>/i,
`<html$1><head>${CSP_META_TAG}</head>`,
);
}
return `<html><head>${CSP_META_TAG}</head><body>${html}</body></html>`;
}
/**
* Safe-scheme allowlist for markdown anchors. Permits absolute http(s), mailto,
* in-page fragments, and root-relative paths — but rejects protocol-relative
* `//evil.com` and dangerous schemes (`javascript:`, `data:`, …) so
* user-supplied markdown can't smuggle a script-bearing link.
*/
export const SAFE_HREF = /^(https?:|mailto:|#|\/(?!\/))/i;
/** Whether a markdown anchor `href` is safe to render as a real `<a>`. */
export function isSafeHref(href: string | undefined): boolean {
return typeof href === "string" && SAFE_HREF.test(href);
}

View file

@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { StrictMode } from "react";
import { renderHook } from "@testing-library/react";
import { useObjectUrl } from "./useObjectUrl";
// Revocation is deferred to a microtask; let the queue drain before asserting.
const flushMicrotasks = () => Promise.resolve();
describe("useObjectUrl", () => {
beforeEach(() => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("creates an object URL for a blob and revokes it on unmount", async () => {
const blob = new Blob(["x"], { type: "text/plain" });
const { result, unmount } = renderHook(() => useObjectUrl(blob));
expect(result.current).toBe("blob:mock-url");
expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
unmount();
await flushMicrotasks();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
});
it("revokes the previous URL when the blob changes", async () => {
// Unique URLs per call so the cleanup effect's [url] dep actually changes.
let n = 0;
vi.mocked(URL.createObjectURL).mockImplementation(() => `blob:url-${n++}`);
const first = new Blob(["a"]);
const second = new Blob(["b"]);
const { rerender } = renderHook(({ blob }) => useObjectUrl(blob), {
initialProps: { blob: first },
});
rerender({ blob: second });
await flushMicrotasks();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:url-0");
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
});
it("keeps the committed URL live under StrictMode double-invoke", async () => {
// StrictMode runs effects setup → cleanup → setup with no re-render. A
// synchronous revoke in cleanup would kill the committed URL; the deferred,
// ref-guarded revoke must leave the URL consumers receive untouched.
let n = 0;
const revoked: string[] = [];
vi.mocked(URL.createObjectURL).mockImplementation(() => `blob:url-${n++}`);
vi.mocked(URL.revokeObjectURL).mockImplementation((url) => {
revoked.push(url as string);
});
const blob = new Blob(["x"]);
const { result } = renderHook(() => useObjectUrl(blob), {
wrapper: StrictMode,
});
await flushMicrotasks();
expect(result.current).toBeDefined();
expect(revoked).not.toContain(result.current);
});
});

View file

@ -0,0 +1,40 @@
import { useEffect, useMemo, useRef } from "react";
/**
* Create an object URL for `blob` and revoke it when the blob changes or the
* component unmounts. Memoize the `Blob` in the caller (e.g. with `useMemo`) so
* a stable blob identity doesn't re-create the URL on every render.
*
* The URL is derived during render (via `useMemo`) so consumers get a live URL
* on the first paint. Revocation is **deferred to a microtask** and guarded by
* `liveUrlRef`, which always points at the currently-mounted URL:
*
* - Under React StrictMode (dev) the effect runs setup → cleanup → setup with
* no re-render. The cleanup schedules the revoke; the re-setup restores
* `liveUrlRef` to the same URL, so when the microtask runs it sees the URL
* is still live and skips the revoke — the iframe keeps a valid `src`.
* - A real unmount (no re-setup) or a blob change (a new URL takes over) leaves
* `liveUrlRef` pointing elsewhere, so the stale URL is released.
*
* Revoking synchronously in the cleanup (the obvious shape) would instead kill
* the committed URL under StrictMode, blanking PDF/HTML previews in dev. This
* mirrors the deferred-disposal trick in `AppRenderer`.
*/
export function useObjectUrl(blob: Blob): string {
const url = useMemo(() => URL.createObjectURL(blob), [blob]);
const liveUrlRef = useRef<string | null>(null);
useEffect(() => {
liveUrlRef.current = url;
return () => {
liveUrlRef.current = null;
queueMicrotask(() => {
if (liveUrlRef.current !== url) {
URL.revokeObjectURL(url);
}
});
};
}, [url]);
return url;
}

View file

@ -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");
});
});
});
});

Some files were not shown because too many files have changed in this diff Show more