Skip to content

Commit 2135d80

Browse files
refactor: replace react-syntax-highlighter with shiki in CodeBlock (#149)
* refactor: replace react-syntax-highlighter with shiki in CodeBlock * Create perky-wolves-grow.md --------- Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com>
1 parent 686577d commit 2135d80

File tree

6 files changed

+135
-261
lines changed

6 files changed

+135
-261
lines changed

.changeset/perky-wolves-grow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ai-elements": patch
3+
---
4+
5+
refactor: replace react-syntax-highlighter with shiki in CodeBlock

packages/elements/__tests__/code-block.test.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from "@testing-library/react";
1+
import { render, screen, waitFor } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { describe, expect, it, vi } from "vitest";
44
import { CodeBlock, CodeBlockCopyButton } from "../src/code-block";
@@ -11,22 +11,26 @@ Object.assign(navigator, {
1111
});
1212

1313
describe("CodeBlock", () => {
14-
it("renders code content", () => {
14+
it("renders code content", async () => {
1515
const { container } = render(
1616
<CodeBlock code="const foo = 'bar';" language="javascript" />
1717
);
18-
expect(container.textContent).toContain("const foo");
18+
await waitFor(() => {
19+
expect(container.textContent).toContain("const foo");
20+
});
1921
});
2022

21-
it("renders with line numbers", () => {
23+
it("renders with line numbers", async () => {
2224
const { container } = render(
2325
<CodeBlock
2426
code="line1\nline2"
2527
language="javascript"
2628
showLineNumbers={true}
2729
/>
2830
);
29-
expect(container.textContent).toContain("line1");
31+
await waitFor(() => {
32+
expect(container.textContent).toContain("line1");
33+
});
3034
});
3135

3236
it("renders children actions", () => {
@@ -43,6 +47,8 @@ describe("CodeBlock", () => {
4347
<CodeBlock className="custom-class" code="code" language="javascript" />
4448
);
4549
expect(container.firstChild).toHaveClass("custom-class");
50+
expect(container.firstChild).toHaveClass("group");
51+
expect(container.firstChild).toHaveClass("relative");
4652
});
4753
});
4854

packages/elements/__tests__/tool.test.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from "@testing-library/react";
1+
import { render, screen, waitFor } from "@testing-library/react";
22
import { describe, expect, it } from "vitest";
33
import {
44
Tool,
@@ -137,7 +137,7 @@ describe("ToolContent", () => {
137137
});
138138

139139
describe("ToolInput", () => {
140-
it("renders input parameters", () => {
140+
it("renders input parameters", async () => {
141141
const input = { query: "test search" };
142142
render(
143143
<Tool defaultOpen>
@@ -148,7 +148,9 @@ describe("ToolInput", () => {
148148
</Tool>
149149
);
150150
expect(screen.getByText("Parameters")).toBeInTheDocument();
151-
expect(screen.getAllByText(/"query"/)[0]).toBeInTheDocument();
151+
await waitFor(() => {
152+
expect(screen.getAllByText(/"query"/)[0]).toBeInTheDocument();
153+
});
152154
});
153155

154156
it("renders JSON formatted input", () => {

packages/elements/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"motion": "^12.23.24",
2222
"nanoid": "^5.1.6",
2323
"react": "19.2.0",
24-
"react-syntax-highlighter": "^15.6.6",
24+
"shiki": "3.13.0",
2525
"streamdown": "^1.4.0",
2626
"tokenlens": "^1.3.1",
2727
"use-stick-to-bottom": "^1.1.1"
@@ -31,8 +31,8 @@
3131
"@testing-library/jest-dom": "^6.9.1",
3232
"@testing-library/react": "^16.3.0",
3333
"@testing-library/user-event": "^14.6.1",
34+
"@types/hast": "^3.0.4",
3435
"@types/react": "19.2.2",
35-
"@types/react-syntax-highlighter": "^15.5.13",
3636
"@vitejs/plugin-react": "^5.0.4",
3737
"@vitest/coverage-v8": "^4.0.1",
3838
"jsdom": "^27.0.1",

packages/elements/src/code-block.tsx

Lines changed: 106 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@
22

33
import { Button } from "@repo/shadcn-ui/components/ui/button";
44
import { cn } from "@repo/shadcn-ui/lib/utils";
5+
import type { Element } from "hast";
56
import { CheckIcon, CopyIcon } from "lucide-react";
6-
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
7-
import { createContext, useContext, useState } from "react";
8-
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
97
import {
10-
oneDark,
11-
oneLight,
12-
} from "react-syntax-highlighter/dist/esm/styles/prism";
8+
type ComponentProps,
9+
createContext,
10+
type HTMLAttributes,
11+
useContext,
12+
useEffect,
13+
useRef,
14+
useState,
15+
} from "react";
16+
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
17+
18+
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
19+
code: string;
20+
language: BundledLanguage;
21+
showLineNumbers?: boolean;
22+
};
1323

1424
type CodeBlockContextType = {
1525
code: string;
@@ -19,85 +29,106 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
1929
code: "",
2030
});
2131

22-
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
23-
code: string;
24-
language: string;
25-
showLineNumbers?: boolean;
26-
children?: ReactNode;
32+
const lineNumberTransformer: ShikiTransformer = {
33+
name: "line-numbers",
34+
line(node: Element, line: number) {
35+
node.children.unshift({
36+
type: "element",
37+
tagName: "span",
38+
properties: {
39+
className: [
40+
"inline-block",
41+
"min-w-10",
42+
"mr-4",
43+
"text-right",
44+
"select-none",
45+
"text-muted-foreground",
46+
],
47+
},
48+
children: [{ type: "text", value: String(line) }],
49+
});
50+
},
2751
};
2852

53+
export async function highlightCode(
54+
code: string,
55+
language: BundledLanguage,
56+
showLineNumbers = false
57+
) {
58+
const transformers: ShikiTransformer[] = showLineNumbers
59+
? [lineNumberTransformer]
60+
: [];
61+
62+
return await Promise.all([
63+
codeToHtml(code, {
64+
lang: language,
65+
theme: "one-light",
66+
transformers,
67+
}),
68+
codeToHtml(code, {
69+
lang: language,
70+
theme: "one-dark-pro",
71+
transformers,
72+
}),
73+
]);
74+
}
75+
2976
export const CodeBlock = ({
3077
code,
3178
language,
3279
showLineNumbers = false,
3380
className,
3481
children,
3582
...props
36-
}: CodeBlockProps) => (
37-
<CodeBlockContext.Provider value={{ code }}>
38-
<div
39-
className={cn(
40-
"relative w-full overflow-hidden rounded-md border bg-background text-foreground",
41-
className
42-
)}
43-
{...props}
44-
>
45-
<div className="relative">
46-
<SyntaxHighlighter
47-
className="overflow-hidden dark:hidden"
48-
codeTagProps={{
49-
className: "font-mono text-sm",
50-
}}
51-
customStyle={{
52-
margin: 0,
53-
padding: "1rem",
54-
fontSize: "0.875rem",
55-
background: "hsl(var(--background))",
56-
color: "hsl(var(--foreground))",
57-
}}
58-
language={language}
59-
lineNumberStyle={{
60-
color: "hsl(var(--muted-foreground))",
61-
paddingRight: "1rem",
62-
minWidth: "2.5rem",
63-
}}
64-
showLineNumbers={showLineNumbers}
65-
style={oneLight}
66-
>
67-
{code}
68-
</SyntaxHighlighter>
69-
<SyntaxHighlighter
70-
className="hidden overflow-hidden dark:block"
71-
codeTagProps={{
72-
className: "font-mono text-sm",
73-
}}
74-
customStyle={{
75-
margin: 0,
76-
padding: "1rem",
77-
fontSize: "0.875rem",
78-
background: "hsl(var(--background))",
79-
color: "hsl(var(--foreground))",
80-
}}
81-
language={language}
82-
lineNumberStyle={{
83-
color: "hsl(var(--muted-foreground))",
84-
paddingRight: "1rem",
85-
minWidth: "2.5rem",
86-
}}
87-
showLineNumbers={showLineNumbers}
88-
style={oneDark}
89-
>
90-
{code}
91-
</SyntaxHighlighter>
92-
{children && (
93-
<div className="absolute top-2 right-2 flex items-center gap-2">
94-
{children}
95-
</div>
83+
}: CodeBlockProps) => {
84+
const [html, setHtml] = useState<string>("");
85+
const [darkHtml, setDarkHtml] = useState<string>("");
86+
const mounted = useRef(false);
87+
88+
useEffect(() => {
89+
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
90+
if (!mounted.current) {
91+
setHtml(light);
92+
setDarkHtml(dark);
93+
mounted.current = true;
94+
}
95+
});
96+
97+
return () => {
98+
mounted.current = false;
99+
};
100+
}, [code, language, showLineNumbers]);
101+
102+
return (
103+
<CodeBlockContext.Provider value={{ code }}>
104+
<div
105+
className={cn(
106+
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
107+
className
96108
)}
109+
{...props}
110+
>
111+
<div className="relative">
112+
<div
113+
className="overflow-hidden dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
114+
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
115+
dangerouslySetInnerHTML={{ __html: html }}
116+
/>
117+
<div
118+
className="hidden overflow-hidden dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
119+
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
120+
dangerouslySetInnerHTML={{ __html: darkHtml }}
121+
/>
122+
{children && (
123+
<div className="absolute top-2 right-2 flex items-center gap-2">
124+
{children}
125+
</div>
126+
)}
127+
</div>
97128
</div>
98-
</div>
99-
</CodeBlockContext.Provider>
100-
);
129+
</CodeBlockContext.Provider>
130+
);
131+
};
101132

102133
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
103134
onCopy?: () => void;
@@ -117,7 +148,7 @@ export const CodeBlockCopyButton = ({
117148
const { code } = useContext(CodeBlockContext);
118149

119150
const copyToClipboard = async () => {
120-
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
151+
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
121152
onError?.(new Error("Clipboard API not available"));
122153
return;
123154
}

0 commit comments

Comments
 (0)