Skip to content

Commit f260618

Browse files
committed
feat: adding code block that can run on both client & server
1 parent e624591 commit f260618

File tree

9 files changed

+304
-857
lines changed

9 files changed

+304
-857
lines changed

lib/Code/Code.Client.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use client';
2+
3+
import clsx from 'clsx';
4+
import { Components, toJsxRuntime } from 'hast-util-to-jsx-runtime';
5+
import type { JSX } from 'react';
6+
import { Fragment, useLayoutEffect, useState } from 'react';
7+
import { jsx, jsxs } from 'react/jsx-runtime';
8+
import type { BundledLanguage } from 'shiki/bundle/web';
9+
import { CodeBlockComponent } from './Code';
10+
11+
const highlight = async (
12+
code: string,
13+
lang: BundledLanguage,
14+
className: string = '',
15+
theme: 'dark' | 'light' | 'auto' = 'auto',
16+
fileName?: string,
17+
) => {
18+
const codeToHast = await import('shiki/bundle/web').then((mod) => mod.codeToHast);
19+
20+
let themeOrThemes: any = {
21+
themes: {
22+
dark: 'material-theme-ocean',
23+
light: 'min-light',
24+
},
25+
};
26+
27+
if (theme === 'dark') themeOrThemes = { theme: 'material-theme-ocean' };
28+
else if (theme === 'light') themeOrThemes = { theme: 'min-light' };
29+
30+
console.log('themeOrThemes', themeOrThemes);
31+
32+
const out = await codeToHast(code, {
33+
lang,
34+
...themeOrThemes,
35+
});
36+
37+
return toJsxRuntime(out, {
38+
Fragment,
39+
jsx,
40+
jsxs,
41+
components: {
42+
pre: (props: any) => (
43+
<CodeBlockComponent
44+
fileName={fileName}
45+
className={className}
46+
{...props}
47+
></CodeBlockComponent>
48+
),
49+
} as Components,
50+
}) as JSX.Element;
51+
};
52+
53+
export const PreBlockClient = ({
54+
children,
55+
className,
56+
fileName,
57+
lang = 'text',
58+
theme = 'auto',
59+
...rest
60+
}: {
61+
children: any;
62+
className?: string;
63+
fileName?: string;
64+
lang?: BundledLanguage | 'text';
65+
theme?: 'dark' | 'light' | 'auto';
66+
[key: string]: any;
67+
}) => {
68+
const [nodes, setNodes] = useState(<></>);
69+
70+
useLayoutEffect(() => {
71+
void highlight(children ?? '', lang as BundledLanguage, className, theme, fileName).then(
72+
setNodes,
73+
);
74+
}, [children, lang, className, theme, fileName]);
75+
76+
return (
77+
<pre
78+
className={clsx('code-block', className)}
79+
{...rest}
80+
>
81+
{nodes}
82+
</pre>
83+
);
84+
};

lib/Code/Code.Server.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { JSX } from 'react';
2+
import type { BundledLanguage } from 'shiki';
3+
import { codeToHast } from 'shiki';
4+
import { Components, toJsxRuntime } from 'hast-util-to-jsx-runtime';
5+
import { Fragment } from 'react';
6+
import { jsx, jsxs } from 'react/jsx-runtime';
7+
import './Code.css';
8+
import { CodeBlockComponent } from './Code';
9+
10+
interface Props {
11+
children: string;
12+
lang: BundledLanguage;
13+
className: string;
14+
fileName?: string;
15+
}
16+
17+
async function CodeBlock(props: Props) {
18+
let lang = 'text'; // default monospaced text
19+
20+
if (props.lang && props.lang.startsWith('lang-')) {
21+
lang = props.lang.replace('lang-', '');
22+
}
23+
24+
if (props.className && props.className.startsWith('lang-')) {
25+
lang = props.className.replace('lang-', '');
26+
}
27+
28+
const hast = await codeToHast(props.children, {
29+
lang,
30+
themes: {
31+
dark: 'material-theme-ocean',
32+
light: 'min-light',
33+
},
34+
});
35+
36+
return toJsxRuntime(hast, {
37+
Fragment,
38+
jsx,
39+
jsxs,
40+
components: {
41+
pre: (props: any) => (
42+
<CodeBlockComponent
43+
{...props}
44+
fileName={props.fileName}
45+
className={props.className}
46+
></CodeBlockComponent>
47+
),
48+
} as Components,
49+
}) as JSX.Element;
50+
}
51+
52+
async function PreBlock({ children }: { children: any }) {
53+
if ('type' in children && children['type'] === 'code') {
54+
return CodeBlock(children['props']);
55+
}
56+
return children;
57+
}
58+
59+
export { CodeBlock, PreBlock };

lib/Code/Code.tsx

Lines changed: 35 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,35 @@
1-
import type { JSX } from 'react';
2-
import type { BundledLanguage } from 'shiki';
3-
import { codeToHast } from 'shiki';
4-
import { Components, toJsxRuntime } from 'hast-util-to-jsx-runtime';
5-
import { Fragment } from 'react';
6-
import { jsx, jsxs } from 'react/jsx-runtime';
7-
import './Code.css';
8-
9-
interface Props {
10-
children: string;
11-
lang: BundledLanguage;
12-
className: string;
13-
}
14-
15-
async function CodeBlock(props: Props) {
16-
let lang = 'text'; // default monospaced text
17-
18-
if (props.lang && props.lang.startsWith('lang-')) {
19-
lang = props.lang.replace('lang-', '');
20-
}
21-
22-
if (props.className && props.className.startsWith('lang-')) {
23-
lang = props.className.replace('lang-', '');
24-
}
25-
26-
const hast = await codeToHast(props.children, {
27-
lang,
28-
themes: {
29-
dark: 'material-theme-ocean',
30-
light: 'min-light',
31-
},
32-
});
33-
34-
return toJsxRuntime(hast, {
35-
Fragment,
36-
jsx,
37-
jsxs,
38-
components: {
39-
pre: (props: any) => (
40-
<pre
41-
{...props}
42-
className="code-block rounded-xl border"
43-
/>
44-
),
45-
} as Components,
46-
}) as JSX.Element;
47-
}
48-
49-
async function PreBlock({ children }: { children: any }) {
50-
if ('type' in children && children['type'] === 'code') {
51-
return CodeBlock(children['props']);
52-
}
53-
return children;
54-
}
55-
56-
export { CodeBlock, PreBlock };
1+
import clsx from 'clsx';
2+
import { CopyButton } from './Copy';
3+
4+
export const CodeBlockComponent = ({
5+
code,
6+
fileName,
7+
className,
8+
...props
9+
}: {
10+
code: string;
11+
fileName?: string;
12+
className?: string;
13+
}) => {
14+
return (
15+
<div className="text-sm border dark:border-gray-800 shadow-sm rounded-xl leading-6 items-center relative">
16+
{fileName && (
17+
<div className="code-filename font-mono text-xs b-2 border-b h-11 flex items-center bg-gray-50 rounded-t-xl px-3 justify-between dark:bg-gray-900 dark:border-gray-800 dark:text-gray-300">
18+
<span>{fileName}</span>
19+
</div>
20+
)}
21+
22+
<CopyButton
23+
className="absolute top-2 right-2"
24+
value={code}
25+
aria-label="Copy code"
26+
title="Copy code"
27+
></CopyButton>
28+
29+
<pre
30+
{...props}
31+
className={clsx('code-block not-prose px-4 py-3 rounded-xl overflow-x-auto', className)}
32+
/>
33+
</div>
34+
);
35+
};

lib/Code/Copy.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use client';
2+
3+
import { clsx } from 'clsx';
4+
import { CheckIcon, CopyIcon } from 'lucide-react';
5+
import { useEffect, useState } from 'react';
6+
7+
interface CopyButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
8+
value: string;
9+
src?: string;
10+
}
11+
12+
export async function copyToClipboardWithMeta(value: string) {
13+
navigator.clipboard.writeText(value);
14+
}
15+
16+
export function CopyButton({ value, className, src, ...props }: CopyButtonProps) {
17+
const [hasCopied, setHasCopied] = useState(false);
18+
19+
useEffect(() => {
20+
const timer = setTimeout(() => {
21+
setHasCopied(false);
22+
}, 2500);
23+
24+
return () => {
25+
clearTimeout(timer);
26+
};
27+
}, [hasCopied]);
28+
29+
return (
30+
<div className={clsx('group', className)}>
31+
<button
32+
className={clsx(
33+
'relative z-10 h-6 w-6 hover:bg-gray-700 hover:text-gray-50 flex items-center justify-center rounded-lg transition-colors duration-200 dark:hover:bg-gray-800',
34+
)}
35+
onClick={(e) => {
36+
e.preventDefault();
37+
copyToClipboardWithMeta(value);
38+
setHasCopied(true);
39+
}}
40+
type="button"
41+
{...props}
42+
>
43+
<span className="sr-only">Copy</span>
44+
{hasCopied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
45+
</button>
46+
<div className="copy-button-tooltip group-hover:block hidden absolute mt-2 left-[-28px] text-center w-20">
47+
{hasCopied ? 'Copied!' : 'Copy'}
48+
</div>
49+
</div>
50+
);
51+
}

lib/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ export * from './Callout/Callout';
22
export * from './Accordion/Accordion';
33
export * from './Steps/Steps';
44
export * from './Tabs/Tabs';
5-
export * from './Code/Code';
5+
export * from './Code/Code.Server';
6+
export * from './Code/Code.Client';
67
export * from './Typography/Typography';
78
export * from './utils';
89
export * from './Image/ImageZoom';

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@
5252
"prettier": "^3.4.2",
5353
"react": "^19.0.0",
5454
"react-dom": "^19.0.0",
55+
"rollup-preserve-directives": "^1.1.3",
5556
"sass": "^1.83.0",
5657
"semantic-release": "^24.2.0",
57-
"shiki": "^1.24.4",
5858
"tailwind-merge": "^2.6.0",
5959
"tailwindcss": "^3.4.17",
6060
"typescript": "^5.7.2",
@@ -68,8 +68,8 @@
6868
"react-dom": "^19.0.0",
6969
"react-medium-image-zoom": "^5.2.12",
7070
"shiki": "^1.24.4",
71-
"tailwindcss": "^3.4.17",
72-
"tailwind-variants": "^0.3.0"
71+
"tailwind-variants": "^0.3.0",
72+
"tailwindcss": "^3.4.17"
7373
},
7474
"dependencies": {
7575
"clsx": "^2.1.1",

0 commit comments

Comments
 (0)