diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index a14dc7d5..8553cda8 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -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", diff --git a/clients/web/package.json b/clients/web/package.json index 9c3e075d..14f3c1cc 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -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", diff --git a/clients/web/src/components/elements/CodeHighlight/CodeHighlight.stories.tsx b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.stories.tsx new file mode 100644 index 00000000..8c91af13 --- /dev/null +++ b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CodeHighlight } from "./CodeHighlight"; + +const meta: Meta = { + title: "Elements/CodeHighlight", + component: CodeHighlight, +}; + +export default meta; +type Story = StoryObj; + +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: '\n first\n second\n', + }, +}; + +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: "++++++++[>++++[>++>+++<<-]>+>->]", + }, +}; diff --git a/clients/web/src/components/elements/CodeHighlight/CodeHighlight.test.tsx b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.test.tsx new file mode 100644 index 00000000..91049089 --- /dev/null +++ b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.test.tsx @@ -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 }) => ( +
+        {children}
+      
+ ), + { 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(); + // 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(); + 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(); + // 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(); + 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( + <> + + + , + ); + 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( + , + ); + await screen.findByTestId("prism"); + expect(registerLanguage).toHaveBeenCalledTimes(1); + unmount(); + // A second mount finds json already registered: ready synchronously, no + // second registerLanguage call. + renderWithMantine(); + 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( + , + ); + await screen.findByTestId("prism"); + unmount(); + // Runtime is cached now; mounting css registers only the new grammar. + renderWithMantine(); + 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( + <> + + + , + ); + 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( + , + ); + 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(); + await waitFor(() => expect(screen.getByText("# b")).toBeInTheDocument()); + expect(screen.queryByTestId("prism")).not.toBeInTheDocument(); + expect(registerLanguage).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx new file mode 100644 index 00000000..cfce77b8 --- /dev/null +++ b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx @@ -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; + customStyle?: Record; + 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 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 = { + xml: "markup", + html: "markup", + htm: "markup", + svg: "markup", + yml: "yaml", + md: "markdown", +}; + +/** Grammars successfully loaded + registered this session. */ +const registeredLanguages = new Set(); +/** Grammars whose import rejected / are unknown — never retried. */ +const failedLoads = new Set(); +/** In-flight grammar loads, so concurrent mounts share one async call. */ +const loadingPromises = new Map>(); + +/** The shared prism-light runtime component + theme. */ +interface Runtime { + Prism: PrismRuntime; + style: Record; +} + +/** The shared prism-light runtime + theme, loaded once. */ +let runtime: Runtime | null = null; +let runtimePromise: Promise | 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 { + 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 { + 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 {code}; + } + + const { Prism, style } = rt; + return ( + + {code} + + ); +} diff --git a/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx b/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx new file mode 100644 index 00000000..2a710c4e --- /dev/null +++ b/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx @@ -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 ( + + + + {`[Binary content (${mimeType}) — preview not supported]`} + + + + ); +} diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx index ac861fdd..a97ce01f 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx @@ -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: "NewsOne", + }, + 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: "

Report

This renders in a sandboxed iframe.

", + }, + }, +}; + +export const BinaryUnsupported: Story = { + args: { + contents: { + uri: "file:///archive.zip", + mimeType: "application/zip", + blob: "UEsDBAoAAAAAAA==", + }, + }, +}; diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx index 7c2733a3..1b04403f 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx @@ -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 }) => ( +
+      {code}
+    
+ ), +})); + +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( + , + ); + 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(); + // MantineProvider injects a