inspector/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx
Cliff Hall baf79b843f
feat(web): expand ContentViewer to render PDF, CSV, HTML, XML, JSON, CSS (closes #1329) (#1551)
* feat(web): expand ContentViewer to render PDF, CSV, HTML, XML, JSON, CSS (closes #1329)

Turn ContentViewer into a per-MIME dispatch surface for resource content.
It now accepts raw TextResourceContents | BlobResourceContents (in addition
to the existing ContentBlock path), so ResourcePreviewPanel.toContentBlock
collapses to a passthrough and blob-typed resources render natively instead
of falling back to opaque "[Binary content …]" text.

New renderers (clients/web/src/components/elements/):
- CodeHighlight: lazy react-syntax-highlighter prism-light element. Runtime,
  tomorrow theme, and each grammar dynamic-import on first use (one chunk per
  grammar). Module-level registeredLanguages / failedLoads / loadingPromises
  caches; plain Code block until a grammar resolves.
- PdfFrame: iframe over a Blob URL (#view=FitH), revoked on unmount.
- CsvTable: papaparse -> Mantine Table (first 100 rows), plain-Code fallback.
- HtmlFrame: sandbox="" iframe over a Blob URL with an injected CSP meta
  (no script-src, falls through to default-src 'none').
- contentViewerUtils: getMimeKind, base64 decode helpers, formatXml,
  wrapHtmlWithCsp, SAFE_HREF markdown-anchor allowlist (unsafe href -> span).

ResourcePreviewPanel: extend the URI-suffix MIME inference table
(.csv/.json/.xml/.html/.htm/.css/.pdf alongside .md).

Deps: papaparse + react-syntax-highlighter (+ @types). tsconfig paths stubs
redirect the deep RSH imports for typecheck (the @types ambient declarations
are shadowed by the real .js under bundler resolution) — same pattern the
repo uses for react/pino.

Tests cover PDF blob-URL creation + cleanup, CSV table render + fallback,
HTML sandbox attributes + CSP injection, JSON/XML/CSS highlighted output,
the lazy-load/alias/failed-load/shared-runtime paths, and the URI-suffix
MIME-inference paths. validate, test:coverage (per-file gate), test:storybook,
and integration all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): harden ContentViewer blob/CSV rendering per review (#1551)

Address review feedback on the ContentViewer MIME-dispatch PR:

- useObjectUrl: fix a dev-only StrictMode bug where the committed object
  URL was revoked but never recreated (setup→cleanup→setup with no
  re-render), blanking PDF/HTML previews. Revocation is now deferred to a
  microtask and guarded by a "live URL" ref so a StrictMode re-setup
  cancels it, while a real unmount / blob change still releases the URL.
  Avoids set-state-in-effect; mirrors AppRenderer's deferred-disposal.
- atob: guard base64 decoding with tryDecodeBase64ToUtf8 /
  tryDecodeBase64ToBytes so a malformed blob from an external server
  degrades to the binary-content notice instead of throwing during render
  and taking down the whole preview panel.
- CsvTable: surface the 100-row display cap with a
  "Showing first N of M rows" caption (no silent truncation).
- Extract BinaryNotice into its own module so PdfFrame can reuse it
  without an import cycle.

Tests added for the StrictMode path, malformed-base64 fallbacks (PDF +
textual blob), the safe-decode helpers, and the truncation caption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:18:43 -04:00

153 lines
3.7 KiB
TypeScript

import type { Meta, StoryObj } from "@storybook/react-vite";
import { ContentViewer } from "./ContentViewer";
const meta: Meta<typeof ContentViewer> = {
title: "Elements/ContentViewer",
component: ContentViewer,
};
export default meta;
type Story = StoryObj<typeof ContentViewer>;
export const PlainText: Story = {
args: {
block: { type: "text", text: "Hello, world!\nThis is plain text." },
},
};
export const JsonContent: Story = {
args: {
block: {
type: "text",
text: '{"name":"my-app","version":"1.0.0","description":"Sample configuration file"}',
},
},
};
export const ImagePreview: Story = {
args: {
block: {
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAABWUlEQVR4nO3YsQnAMAwAQe+/dLKCRZpD+UA6cVh8p/Oc81z9t1/eJ+/oD/ybVxDMKwjmFQTzCoJ5BcG8gmBeQTCvIJg3mLQX2eIVBPMKgnkFwbyCYF5BMK8gmFcQzCsI5hUE8zqdYF5BMK8gmFcQzCsI5hUE8wqCeQXBvIJgXkEwbzBpL7LFKwjmFQTzCoJ5BcG8gmBeQTCvIJhXEMwrCOZ1OsG8gmBeQTCvIJhXEMwrCOYVBPMKgnkFwbyCYN5g0l5ki1cQzCsI5hUE8wqCeQXBvIJgXkEwryCYVxDM63SCeQXBvIJgXkEwryCYVxDMKwjmFQTzCoJ5BcG8waS9yBavIJhXEMwrCOYVBPMKgnkFwbyCYF5BMK8gmNfpBPMKgnkFwbyCYF5BMK8gmFcQzCsI5hUE8wqCeYNJe5EtXkEwryCYVxDMKwjmFQTzCoJ5BcG8gmBeQTDvBaFNwZ0T5gfxAAAAAElFTkSuQmCC",
mimeType: "image/png",
},
},
};
export const LongJson: Story = {
args: {
block: {
type: "text",
text: JSON.stringify({
name: "my-app",
version: "2.5.0",
description: "A comprehensive application configuration",
dependencies: {
react: "^18.0.0",
typescript: "^5.0.0",
},
scripts: {
dev: "vite",
build: "tsc && vite build",
},
}),
},
},
};
export const WithCopyButton: Story = {
args: {
block: {
type: "text",
text: "Hello, world!\nThis text can be copied.",
},
copyable: true,
},
};
export const JsonWithCopy: Story = {
args: {
block: {
type: "text",
text: '{"name":"my-app","version":"1.0.0","description":"Sample configuration file"}',
},
copyable: true,
},
};
export const ResourceLinkBlock: Story = {
args: {
block: {
type: "resource_link",
uri: "file:///docs/readme.md",
name: "Readme",
description: "Project documentation",
mimeType: "text/markdown",
},
},
};
// --- 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==",
},
},
};