* 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
26 changed files with 2193 additions and 110 deletions
161
clients/web/package-lock.json
generated
161
clients/web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "++++++++[>++++[>++>+++<<-]>+>->]",
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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==",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue