* 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>
219 lines
7.2 KiB
TypeScript
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);
|
|
}
|