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>
This commit is contained in:
Cliff Hall 2026-06-30 09:18:43 -04:00 • committed by GitHub
commit baf79b843f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 2193 additions and 110 deletions

View file

@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CodeHighlight } from "./CodeHighlight";
const meta: Meta<typeof CodeHighlight> = {
title: "Elements/CodeHighlight",
component: CodeHighlight,
};
export default meta;
type Story = StoryObj<typeof CodeHighlight>;
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: '<root>\n <item id="1">first</item>\n <item id="2">second</item>\n</root>',
},
};
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: "++++++++[>++++[>++>+++<<-]>+>->]",
},
};

View file

@ -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 }) => (
<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();
});
});

View file

@ -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<string, unknown>;
customStyle?: Record<string, unknown>;
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<string, () => 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<string, string> = {
xml: "markup",
html: "markup",
htm: "markup",
svg: "markup",
yml: "yaml",
md: "markdown",
};
/** Grammars successfully loaded + registered this session. */
const registeredLanguages = new Set<string>();
/** Grammars whose import rejected / are unknown — never retried. */
const failedLoads = new Set<string>();
/** In-flight grammar loads, so concurrent mounts share one async call. */
const loadingPromises = new Map<string, Promise<void>>();
/** The shared prism-light runtime component + theme. */
interface Runtime {
Prism: PrismRuntime;
style: Record<string, unknown>;
}
/** The shared prism-light runtime + theme, loaded once. */
let runtime: Runtime | null = null;
let runtimePromise: Promise<Runtime> | 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<Runtime> {
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<void> {
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 <PlainCode>{code}</PlainCode>;
}
const { Prism, style } = rt;
return (
<Prism
language={resolved}
style={style}
customStyle={{ margin: 0 }}
wrapLongLines
>
{code}
</Prism>
);
}