* 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:
parent
b2a6ee1dcf
commit
baf79b843f
3 changed files with 2193 additions and 110 deletions
|
|
@ -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: "++++++++[>++++[>++>+++<<-]>+>->]",
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue