* 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>
172 lines
6.9 KiB
TypeScript
172 lines
6.9 KiB
TypeScript
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();
|
|
});
|
|
});
|