inspector/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts
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

219 lines
7.2 KiB
TypeScript

/**
* 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);
}