Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [
"src/components/shared/SecretsManagement/components/SecretsBackendUnavailable.tsx",
"src/components/shared/HighlightText.tsx",
"src/components/shared/AnnouncementBanners.tsx",
"src/components/shared/Markdown",
"src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection",
"src/components/ui/typography.tsx",

Expand Down
191 changes: 191 additions & 0 deletions src/components/shared/Markdown/Markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import { Markdown, UntrustedMarkdown } from "./Markdown";

function fontClasses(element: HTMLElement) {
return element.className
.split(" ")
.filter((name) => name.startsWith("text-"));
}

describe("<Markdown />", () => {
afterEach(cleanup);

it("gives headings a visible hierarchy", () => {
render(<Markdown body={"# One\n\n## Two\n\n### Three\n\n#### Four"} />);

const [h1, h2, h3, h4] = ["One", "Two", "Three", "Four"].map((text) =>
screen.getByText(text),
);

expect(h1.tagName).toBe("H1");
expect(h4.tagName).toBe("H4");
expect(fontClasses(h1)).toContain("text-lg");
expect(fontClasses(h2)).toContain("text-base");
expect(fontClasses(h3)).toContain("text-sm");
expect(h1.className).toContain("font-bold");
expect(h2.className).toContain("font-semibold");
});

it("renders a fenced block as a block, not a run of inline pills", () => {
const { container } = render(
<Markdown body={"```js\nconst a = 1;\nconst b = 2;\n```"} />,
);

const pre = container.querySelector("pre");
expect(pre).not.toBeNull();
expect(pre?.className).toContain("overflow-x-auto");
expect(pre?.querySelector("code")).not.toBeNull();
expect(pre?.textContent).toContain("const b = 2;");
expect(pre?.className).toContain("[&>code]:block");
});

it("renders an unlabelled fence the same way as a labelled one", () => {
const { container } = render(<Markdown body={"```\nplain text\n```"} />);

expect(container.querySelector("pre")?.className).toContain(
"[&>code]:block",
);
expect(container.querySelector("pre code")?.textContent).toContain(
"plain text",
);
});

it("renders GFM tables with delineated rows", () => {
const { container } = render(
<Markdown body={"| a | b |\n| --- | --- |\n| 1 | 2 |"} />,
);

expect(container.querySelector("table")).not.toBeNull();
expect(container.querySelector("thead")?.className).toContain(
"bg-muted/50",
);
expect(container.querySelector("tr")?.className).toContain("border-b");
expect(container.querySelector("td")?.className).toContain("px-2");
});

it("drops the bullet from a task list item", () => {
const { container } = render(
<Markdown body={"- [ ] pending\n- [x] done"} />,
);

expect(container.querySelector("li")?.className).toContain("list-none");
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(
2,
);
});

it("does not render raw HTML", () => {
const { container } = render(
<Markdown body={"<script>window.__mdXss = true;</script><b>bold</b>"} />,
);

expect(container.querySelector("script")).toBeNull();
expect(screen.queryByText("bold")).not.toBeInTheDocument();
expect(
(window as unknown as Record<string, unknown>).__mdXss,
).toBeUndefined();
});

it("renders an image and keeps a relative link", () => {
const { container } = render(
<Markdown body={"![Chart](/local.png) and [runs](/runs)"} />,
);

expect(container.querySelector("img")).toHaveAttribute("src", "/local.png");
expect(screen.getByRole("link", { name: "runs" })).toHaveAttribute(
"href",
"/runs",
);
});

it("keeps an internal link in the same tab and sends an external one out", () => {
render(
<Markdown body={"[runs](/runs) and [docs](https://example.com/docs)"} />,
);

const internal = screen.getByRole("link", { name: "runs" });
expect(internal).not.toHaveAttribute("target");
expect(internal).not.toHaveAttribute("rel");
expect(internal.querySelector("svg")).toBeNull();

const external = screen.getByRole("link", { name: "docs" });
expect(external).toHaveAttribute("target", "_blank");
expect(external).toHaveAttribute("rel", "noopener noreferrer");
expect(external.querySelector("svg")).not.toBeNull();
});

it("renders links through the design system, inline with the text", () => {
const { container } = render(
<Markdown body="Read the [notes](https://example.com/docs) today." />,
);

const link = screen.getByRole("link", { name: "notes" });
expect(link.className).toContain("inline-flex");
expect(container.querySelector("p a")).toBe(link);
expect(container.querySelector("a div")).toBeNull();
});

it("lets a caller override a base element", () => {
render(
<Markdown
body="text"
components={{
p: ({ children }) => <div data-testid="custom">{children}</div>,
}}
/>,
);

expect(screen.getByTestId("custom")).toHaveTextContent("text");
});
});

describe("<UntrustedMarkdown />", () => {
afterEach(cleanup);

it("shows an image's alt text without requesting the image", () => {
const { container } = render(
<UntrustedMarkdown
body={"![Diagram of the outage](https://elsewhere.example/pixel.png)"}
/>,
);

expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("Diagram of the outage")).toBeInTheDocument();
});

it("renders an absolute http link as an external anchor", () => {
render(<UntrustedMarkdown body="[docs](https://example.com/docs)" />);

const link = screen.getByRole("link", { name: "docs" });
expect(link).toHaveAttribute("href", "https://example.com/docs");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});

it("renders a rejected link as plain text rather than a dead anchor", () => {
const { container } = render(
<UntrustedMarkdown
body={"[script](javascript:alert(1)) and [relative](/runs)"}
/>,
);

expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(container.querySelector("a")).toBeNull();
expect(container.textContent).toBe("script and relative");
});

it("cannot have its image guard overridden by a caller", () => {
const { container } = render(
<UntrustedMarkdown
body={"![Diagram](https://elsewhere.example/pixel.png)"}
components={{
img: ({ src }) => <img src={src} data-testid="leaked" alt="" />,
}}
/>,
);

expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("Diagram")).toBeInTheDocument();
});
});
185 changes: 185 additions & 0 deletions src/components/shared/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import type { ComponentProps, ReactNode } from "react";
import ReactMarkdown, {
type Components,
defaultUrlTransform,
} from "react-markdown";
import remarkGfm from "remark-gfm";

import { Link } from "@/components/ui/link";
import { Separator } from "@/components/ui/separator";
import { Heading, Paragraph, Text } from "@/components/ui/typography";
import { cn } from "@/lib/utils";
import { toAbsoluteHttpUrl } from "@/utils/URL";

interface ElementProps {
children?: ReactNode;
className?: string;
}

export const INLINE_CODE_CLASS =
"rounded bg-muted px-1 py-0.5 text-xs font-mono";

type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;

const HEADING_STYLES: Record<
HeadingLevel,
Omit<ComponentProps<typeof Heading>, "level" | "children">
> = {
1: { size: "lg", weight: "bold", className: "mt-2 mb-1 block" },
2: { size: "md", weight: "semibold", className: "mt-2 mb-1 block" },
3: { size: "sm", weight: "semibold", className: "mt-2 mb-1 block" },
4: {
size: "sm",
weight: "semibold",
tone: "subdued",
className: "mt-1 block",
},
5: {
size: "xs",
weight: "semibold",
tone: "subdued",
className: "mt-1 block",
},
6: {
size: "xs",
weight: "semibold",
tone: "subdued",
className: "mt-1 block",
},
};

function renderHeading(level: HeadingLevel) {
const MarkdownHeading = ({ children }: ElementProps) => (
<Heading level={level} {...HEADING_STYLES[level]}>
{children}
</Heading>
);

return MarkdownHeading;
}

const baseComponents = {
p: ({ children }: ElementProps) => (
<Paragraph size="sm" className="my-1 leading-relaxed">
{children}
</Paragraph>
),
a: ({ href, children }: ElementProps & { href?: string }) => {
if (!href) return <>{children}</>;

return (
<Link
href={href}
size="sm"
variant="primary"
external={toAbsoluteHttpUrl(href) !== null}
>
{children}
</Link>
);
},
h1: renderHeading(1),
h2: renderHeading(2),
h3: renderHeading(3),
h4: renderHeading(4),
h5: renderHeading(5),
h6: renderHeading(6),
ul: ({ children, className }: ElementProps) => (
<ul
className={cn(
"my-1",
className?.includes("contains-task-list")
? "pl-0"
: "list-disc pl-4 marker:text-muted-foreground",
)}
>
{children}
</ul>
),
ol: ({ children }: ElementProps) => (
<ol className="list-decimal pl-4 my-1 marker:text-muted-foreground">
{children}
</ol>
),
li: ({ children, className }: ElementProps) => (
<li
className={cn(
"my-0.5 [&>input]:mr-1.5 [&>input]:align-middle",
className?.includes("task-list-item") && "list-none",
)}
>
{children}
</li>
),
blockquote: ({ children }: ElementProps) => (
<blockquote className="border-l-2 border-muted-foreground/30 pl-3 my-1 italic text-muted-foreground">
{children}
</blockquote>
),
code: ({ children }: ElementProps) => (
Comment thread
camielvs marked this conversation as resolved.
<code className={INLINE_CODE_CLASS}>{children}</code>
),
pre: ({ children }: ElementProps) => (
<pre className="my-2 overflow-x-auto [&>code]:block [&>code]:p-2">
{children}
</pre>
),
table: ({ children }: ElementProps) => (
<div className="my-2 overflow-x-auto rounded-md border">
<table className="w-full text-xs">{children}</table>
</div>
),
thead: ({ children }: ElementProps) => (
<thead className="bg-muted/50">{children}</thead>
),
tr: ({ children }: ElementProps) => (
<tr className="border-b last:border-b-0">{children}</tr>
),
th: ({ children }: ElementProps) => (
<th className="px-2 py-1 text-left font-semibold">{children}</th>
),
td: ({ children }: ElementProps) => (
<td className="px-2 py-1 align-top">{children}</td>
),
img: ({ alt, src }: { alt?: string; src?: string }) => (
<img src={src} alt={alt} className="my-2 max-w-full rounded" />
),
hr: () => <Separator className="my-2" />,
} satisfies Components;
const AltTextOnlyImage = ({ alt }: { alt?: string }) =>
alt ? (
<Text size="xs" tone="subdued">
{alt}
</Text>
) : null;

interface MarkdownProps {
body: string;
components?: Components;
urlTransform?: (url: string) => string;
}

export const Markdown = ({
body,
components,
urlTransform = defaultUrlTransform,
}: MarkdownProps) => (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{ ...baseComponents, ...components }}
urlTransform={urlTransform}
>
{body}
</ReactMarkdown>
);

export const UntrustedMarkdown = ({
body,
components,
}: Omit<MarkdownProps, "urlTransform">) => (
<Markdown
body={body}
components={{ ...components, img: AltTextOnlyImage }}
urlTransform={(url) => toAbsoluteHttpUrl(url) ?? ""}
/>
);
Loading
Loading