feat(dashboard): shared markdown renders embedded HTML (sanitized) + mermaid diagrams
MailboxMessageContent pipeline gains rehype-raw -> rehype-sanitize (GitHub-like allow list: details/summary/kbd/tables/etc; strips script/style/iframe/on*/javascript:) so raw HTML in markdown renders and HTML comments are dropped. Fenced ```mermaid blocks render via a lazy-loaded MermaidDiagram component (dynamic import keeps mermaid out of the main bundle; theme-aware; falls back to the raw block on parse error). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/markdown-raw-html-and-mermaid.md
Normal file
5
.changeset/markdown-raw-html-and-mermaid.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
The shared markdown renderer (GitHub PR/issue bodies + comments, mailbox, chat) now renders embedded raw HTML and mermaid diagrams. Raw HTML (`<details>`/`<summary>`, `<kbd>`, `<sub>`, tables) renders as real elements via `rehype-raw`, with `rehype-sanitize` stripping XSS (script/style/iframe, event handlers, `javascript:` URLs) since these bodies come from GitHub; HTML comments (`<!-- -->`) are dropped. Fenced ```mermaid blocks render as actual diagrams via a lazy-loaded `mermaid` import (kept out of the main bundle, loaded only when a diagram is present), falling back to the raw code block on parse error and following the dashboard theme.
|
||||||
@@ -1,8 +1,51 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||||
|
import type { Options as SanitizeSchema } from "rehype-sanitize";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
|
import type { PluggableList } from "unified";
|
||||||
import { linkifyReactChildren } from "../utils/filePathLinkify";
|
import { linkifyReactChildren } from "../utils/filePathLinkify";
|
||||||
|
import { MermaidDiagram } from "./MermaidDiagram";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Markdown 2026-06-23-03:15:
|
||||||
|
GitHub PR/issue bodies + comments (and mailbox/chat) embed raw HTML (`<details>`,
|
||||||
|
`<summary>`, `<kbd>`, `<sub>`, tables), HTML comments (`<!-- -->`), and ```mermaid
|
||||||
|
blocks. Previously raw HTML was escaped to literal text and mermaid showed as code.
|
||||||
|
|
||||||
|
Pipeline (ORDER MATTERS): remark-gfm -> rehype-raw -> rehype-sanitize.
|
||||||
|
- rehype-raw parses embedded HTML into the hast tree so it renders as real elements.
|
||||||
|
It also DROPS HTML comments by default, so `<!-- ... -->` never appears in output.
|
||||||
|
- rehype-sanitize runs AFTER raw to strip XSS: <script>/<style>/<iframe>, event
|
||||||
|
handlers (onClick etc.), and javascript: URLs. Because these bodies come from
|
||||||
|
GitHub (untrusted), sanitize is mandatory — raw without sanitize would be an XSS
|
||||||
|
hole. Running sanitize last guarantees nothing injected via raw survives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Markdown 2026-06-23-03:15:
|
||||||
|
Sanitize schema = rehype-sanitize defaultSchema (a conservative GitHub-like allow
|
||||||
|
list that already permits details/summary/kbd/sub/sup/b/i/em/strong/a/img/code/pre/
|
||||||
|
tables/br/hr/blockquote/lists/headings/span/div and strips script/style/event
|
||||||
|
handlers/javascript: URLs) EXTENDED to ensure the `className` attribute survives on
|
||||||
|
common elements (needed for our `language-*` code fences and styled wrappers). We do
|
||||||
|
NOT widen tagNames beyond defaults, so script/style/iframe stay stripped.
|
||||||
|
*/
|
||||||
|
const mailboxSanitizeSchema: SanitizeSchema = {
|
||||||
|
...defaultSchema,
|
||||||
|
attributes: {
|
||||||
|
...defaultSchema.attributes,
|
||||||
|
// Preserve className on code/span/div/pre so language fences + wrapper styling work.
|
||||||
|
code: [...(defaultSchema.attributes?.code ?? []), "className"],
|
||||||
|
span: [...(defaultSchema.attributes?.span ?? []), "className"],
|
||||||
|
div: [...(defaultSchema.attributes?.div ?? []), "className"],
|
||||||
|
pre: [...(defaultSchema.attributes?.pre ?? []), "className"],
|
||||||
|
// `<details open>` disclosure state should round-trip.
|
||||||
|
details: [...(defaultSchema.attributes?.details ?? []), "open"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const mailboxMarkdownComponents: Components = {
|
const mailboxMarkdownComponents: Components = {
|
||||||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||||
@@ -17,8 +60,26 @@ const mailboxMarkdownComponents: Components = {
|
|||||||
{children}
|
{children}
|
||||||
</table>
|
</table>
|
||||||
),
|
),
|
||||||
// Open links in a new tab. ReactMarkdown does not allow raw HTML by default,
|
/*
|
||||||
// so the rendered output here is safe.
|
FNXC:Markdown 2026-06-23-03:15:
|
||||||
|
Code-block override: a fenced ```mermaid block arrives as `<code class="language-mermaid">`.
|
||||||
|
Render it via <MermaidDiagram>, which lazy-imports mermaid so the heavy library is
|
||||||
|
only pulled in when a diagram is present. All other code (inline + other languages)
|
||||||
|
keeps the default rendering.
|
||||||
|
*/
|
||||||
|
code: ({ className, children, ...props }) => {
|
||||||
|
if (className === "language-mermaid") {
|
||||||
|
const chart = String(children ?? "").replace(/\n$/, "");
|
||||||
|
return <MermaidDiagram chart={chart} testId="mailbox-mermaid-diagram" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Open links in a new tab. Sanitize strips javascript: URLs and event handlers
|
||||||
|
// before this runs, so href is safe.
|
||||||
a: ({ children, ...props }) => (
|
a: ({ children, ...props }) => (
|
||||||
<a {...props} target="_blank" rel="noopener noreferrer">
|
<a {...props} target="_blank" rel="noopener noreferrer">
|
||||||
{children}
|
{children}
|
||||||
@@ -26,6 +87,10 @@ const mailboxMarkdownComponents: Components = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const remarkPlugins: PluggableList = [remarkGfm];
|
||||||
|
// Raw must run before sanitize: parse HTML, then strip anything unsafe.
|
||||||
|
const rehypePlugins: PluggableList = [rehypeRaw, [rehypeSanitize, mailboxSanitizeSchema]];
|
||||||
|
|
||||||
interface MailboxMessageContentProps {
|
interface MailboxMessageContentProps {
|
||||||
/** Raw message body. Rendered as GitHub-flavored markdown. */
|
/** Raw message body. Rendered as GitHub-flavored markdown. */
|
||||||
content: string;
|
content: string;
|
||||||
@@ -38,9 +103,10 @@ interface MailboxMessageContentProps {
|
|||||||
/**
|
/**
|
||||||
* Renders a mailbox message body as GitHub-flavored markdown.
|
* Renders a mailbox message body as GitHub-flavored markdown.
|
||||||
*
|
*
|
||||||
* Uses ReactMarkdown defaults (no raw HTML) so untrusted message content is
|
* Supports embedded raw HTML (details/summary/kbd/sub/tables) via rehype-raw, with
|
||||||
* safe. Plain-text messages render unchanged (markdown is a strict superset
|
* rehype-sanitize stripping XSS (script/style/iframe/event-handlers/javascript:).
|
||||||
* for the formatting we care about — bold, lists, code, links, tables).
|
* Fenced ```mermaid blocks render as diagrams via the lazy-loaded MermaidDiagram.
|
||||||
|
* HTML comments (`<!-- -->`) are dropped and never rendered.
|
||||||
*
|
*
|
||||||
* Memoized because mailbox detail panes can re-render on selection / SSE
|
* Memoized because mailbox detail panes can re-render on selection / SSE
|
||||||
* updates while the underlying message body is unchanged.
|
* updates while the underlying message body is unchanged.
|
||||||
@@ -55,7 +121,11 @@ export const MailboxMessageContent = memo(function MailboxMessageContent({
|
|||||||
: "mailbox-markdown";
|
: "mailbox-markdown";
|
||||||
return (
|
return (
|
||||||
<div className={wrapperClass} data-testid={testId}>
|
<div className={wrapperClass} data-testid={testId}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mailboxMarkdownComponents}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={remarkPlugins}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={mailboxMarkdownComponents}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -371,6 +371,35 @@
|
|||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Markdown 2026-06-23-03:15:
|
||||||
|
Wrappers for embedded raw HTML + mermaid diagrams. `<details>` from GitHub bodies
|
||||||
|
needs a clickable summary affordance; mermaid SVGs should scroll horizontally rather
|
||||||
|
than overflow the message column. Theme tokens only — no hard-coded colors.
|
||||||
|
*/
|
||||||
|
.mailbox-markdown details {
|
||||||
|
border: var(--btn-border-width) solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-markdown summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-mermaid {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-mermaid svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-reply-context-wrapper {
|
.mailbox-reply-context-wrapper {
|
||||||
margin-bottom: var(--space-xs);
|
margin-bottom: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|||||||
95
packages/dashboard/app/components/MermaidDiagram.tsx
Normal file
95
packages/dashboard/app/components/MermaidDiagram.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Markdown 2026-06-23-03:15:
|
||||||
|
GitHub PR/issue bodies and comments embed ```mermaid fenced blocks. Render them
|
||||||
|
as real diagrams instead of literal code. The `mermaid` library is heavy (~600kb+
|
||||||
|
of parser/renderer), so it is LAZY-LOADED via `await import("mermaid")` only when a
|
||||||
|
mermaid block is actually present — keeping it out of the main dashboard bundle.
|
||||||
|
|
||||||
|
Race/unmount safety: each render gets a unique element id, an incrementing render
|
||||||
|
token guards against overlapping async renders (theme/chart change mid-flight), and
|
||||||
|
an `unmounted` flag prevents state updates after teardown. On parse error we fall
|
||||||
|
back to the raw fenced code block so a malformed diagram never crashes the message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let mermaidIdCounter = 0;
|
||||||
|
|
||||||
|
/** Theme follows the dashboard token: `data-theme="light"` => mermaid `default`, else `dark`. */
|
||||||
|
function resolveMermaidTheme(): "dark" | "default" {
|
||||||
|
if (typeof document === "undefined") return "default";
|
||||||
|
return document.documentElement.dataset.theme === "light" ? "default" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MermaidDiagramProps {
|
||||||
|
/** Raw mermaid source from the fenced ```mermaid block. */
|
||||||
|
chart: string;
|
||||||
|
/** Optional data-testid for test selectors. */
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a mermaid diagram from raw mermaid source.
|
||||||
|
*
|
||||||
|
* Lazy-imports `mermaid` inside an effect, calls `mermaid.render` to produce an
|
||||||
|
* SVG string, and injects it. On any parse/render failure, falls back to the raw
|
||||||
|
* code block so the surrounding message keeps rendering.
|
||||||
|
*/
|
||||||
|
export const MermaidDiagram = memo(function MermaidDiagram({
|
||||||
|
chart,
|
||||||
|
testId,
|
||||||
|
}: MermaidDiagramProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [errored, setErrored] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unmounted = false;
|
||||||
|
// Bump the token per effect run; only the latest run is allowed to commit.
|
||||||
|
const renderToken = ++mermaidIdCounter;
|
||||||
|
const elementId = `mermaid-${renderToken}`;
|
||||||
|
|
||||||
|
setErrored(false);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const mermaidModule = await import("mermaid");
|
||||||
|
const mermaid = mermaidModule.default;
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
theme: resolveMermaidTheme(),
|
||||||
|
securityLevel: "strict",
|
||||||
|
});
|
||||||
|
const { svg } = await mermaid.render(elementId, chart);
|
||||||
|
if (unmounted || renderToken !== mermaidIdCounter) return;
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = svg;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (unmounted || renderToken !== mermaidIdCounter) return;
|
||||||
|
setErrored(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unmounted = true;
|
||||||
|
};
|
||||||
|
}, [chart]);
|
||||||
|
|
||||||
|
if (errored) {
|
||||||
|
// Fallback: show the raw mermaid source as a normal code block.
|
||||||
|
return (
|
||||||
|
<pre className="mailbox-markdown-pre mailbox-mermaid-fallback" data-testid={testId}>
|
||||||
|
<code>{chart}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="mailbox-mermaid"
|
||||||
|
data-testid={testId}
|
||||||
|
aria-label="Mermaid diagram"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, cleanup, screen } from "@testing-library/react";
|
import { render, cleanup, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||||
import { MailboxMessageContent } from "../MailboxMessageContent";
|
import { MailboxMessageContent } from "../MailboxMessageContent";
|
||||||
|
|
||||||
|
// FNXC:Markdown 2026-06-23-03:15: Mock the heavy `mermaid` library so the mermaid
|
||||||
|
// rendering tests do not pull in the real parser/renderer bundle. The component
|
||||||
|
// lazy-imports `mermaid` (default export), so we mock the module default.
|
||||||
|
vi.mock("mermaid", () => ({
|
||||||
|
default: {
|
||||||
|
initialize: vi.fn(),
|
||||||
|
render: vi.fn().mockResolvedValue({ svg: "<svg data-testid='mock-mermaid-svg'></svg>" }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
@@ -71,11 +81,10 @@ describe("MailboxMessageContent", () => {
|
|||||||
expect(table?.querySelectorAll("tbody td")).toHaveLength(2);
|
expect(table?.querySelectorAll("tbody td")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT execute raw HTML in messages", () => {
|
it("sanitizes raw <script> out of messages (no execution, no element)", () => {
|
||||||
const content = "<script>window.__pwned = true;</script>Hello";
|
const content = "<script>window.__pwned = true;</script>Hello";
|
||||||
const { container } = render(<MailboxMessageContent content={content} />);
|
const { container } = render(<MailboxMessageContent content={content} />);
|
||||||
// ReactMarkdown defaults disallow raw HTML — the <script> tag should be
|
// rehype-raw parses HTML, but rehype-sanitize strips <script> before render.
|
||||||
// rendered as escaped text, not as a real script element.
|
|
||||||
expect(container.querySelector("script")).toBeNull();
|
expect(container.querySelector("script")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
(globalThis as unknown as { __pwned?: boolean }).__pwned,
|
(globalThis as unknown as { __pwned?: boolean }).__pwned,
|
||||||
@@ -83,6 +92,53 @@ describe("MailboxMessageContent", () => {
|
|||||||
expect(container.textContent).toContain("Hello");
|
expect(container.textContent).toContain("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders raw <details>/<summary> as a working disclosure element", () => {
|
||||||
|
const content =
|
||||||
|
"<details><summary>More info</summary>Hidden body text here.</details>";
|
||||||
|
const { container } = render(<MailboxMessageContent content={content} />);
|
||||||
|
const details = container.querySelector("details");
|
||||||
|
expect(details).not.toBeNull();
|
||||||
|
expect(details?.querySelector("summary")?.textContent).toBe("More info");
|
||||||
|
expect(details?.textContent).toContain("Hidden body text here.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders other safe raw HTML (kbd/sub) as real elements", () => {
|
||||||
|
const content = "Press <kbd>Cmd</kbd> and H<sub>2</sub>O.";
|
||||||
|
const { container } = render(<MailboxMessageContent content={content} />);
|
||||||
|
expect(container.querySelector("kbd")?.textContent).toBe("Cmd");
|
||||||
|
expect(container.querySelector("sub")?.textContent).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render HTML comments in the output", () => {
|
||||||
|
const content = "Before<!-- secret hidden note -->After";
|
||||||
|
const { container } = render(<MailboxMessageContent content={content} />);
|
||||||
|
expect(container.innerHTML).not.toContain("secret hidden note");
|
||||||
|
expect(container.innerHTML).not.toContain("<!--");
|
||||||
|
expect(container.textContent).toContain("Before");
|
||||||
|
expect(container.textContent).toContain("After");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips javascript: URLs and event handlers from raw HTML", () => {
|
||||||
|
const content = '<a href="javascript:alert(1)" onclick="alert(2)">click</a>';
|
||||||
|
const { container } = render(<MailboxMessageContent content={content} />);
|
||||||
|
const link = container.querySelector("a");
|
||||||
|
// sanitize drops the javascript: href and the onclick handler.
|
||||||
|
expect(link?.getAttribute("href") ?? "").not.toContain("javascript:");
|
||||||
|
expect(link?.getAttribute("onclick")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a ```mermaid block as the MermaidDiagram container", async () => {
|
||||||
|
const content = "```mermaid\ngraph TD; A-->B;\n```";
|
||||||
|
render(<MailboxMessageContent content={content} />);
|
||||||
|
const diagram = await screen.findByTestId("mailbox-mermaid-diagram");
|
||||||
|
expect(diagram).toBeInTheDocument();
|
||||||
|
expect(diagram).toHaveClass("mailbox-mermaid");
|
||||||
|
// The mocked mermaid.render SVG is injected into the container.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(diagram.querySelector("svg")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards testId to the wrapper", () => {
|
it("forwards testId to the wrapper", () => {
|
||||||
render(<MailboxMessageContent content="x" testId="mailbox-message-body" />);
|
render(<MailboxMessageContent content="x" testId="mailbox-message-body" />);
|
||||||
expect(screen.getByTestId("mailbox-message-body")).toBeInTheDocument();
|
expect(screen.getByTestId("mailbox-message-body")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"ioredis": "^5.6.0",
|
"ioredis": "^5.6.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"mermaid": "^11.4.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -138,6 +139,8 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
|
|||||||
706
pnpm-lock.yaml
generated
706
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user