Skip to content

Commit 9edcee5

Browse files
authored
Add syntax highlighting (#178)
1 parent 9a94ad7 commit 9edcee5

File tree

7 files changed

+581
-15
lines changed

7 files changed

+581
-15
lines changed

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"react-use": "^17.6.0",
6565
"recharts": "^2.15.3",
6666
"require-in-the-middle": "^7.5.2",
67+
"shiki": "^3.7.0",
6768
"sonner": "^2.0.3",
6869
"stripe": "^18.2.0",
6970
"tailwind-merge": "^3.3.0",
Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,38 @@
11
'use client';
22

33
import { MermaidDiagram } from './MermaidDiagram';
4-
import type { Element } from 'hast';
4+
import { SyntaxHighlighter } from './SyntaxHighlighter';
55

66
interface CodeBlockProps extends React.HTMLAttributes<HTMLElement> {
77
children?: React.ReactNode;
88
className?: string;
9-
inline?: boolean;
10-
node?: Element;
119
}
1210

1311
export const CodeBlock = ({
1412
children,
1513
className,
16-
inline,
1714
...props
1815
}: CodeBlockProps) => {
19-
// Extract language from className (format: "language-xxx")
2016
const match = /language-(\w+)/.exec(className || '');
2117
const language = match ? match[1] : '';
22-
23-
// Convert children to string
2418
const code = String(children).replace(/\n$/, '');
2519

26-
// If it's inline code or not mermaid, render as regular code
27-
if (inline || language !== 'mermaid') {
20+
// No language = inline code (from `backticks`)
21+
if (!match) {
2822
return (
2923
<code className={className} {...props}>
3024
{children}
3125
</code>
3226
);
3327
}
3428

35-
// If it's a mermaid code block, render with MermaidDiagram
29+
// Mermaid diagrams
3630
if (language === 'mermaid') {
37-
return <MermaidDiagram chart={code} className="my-4" />;
31+
return <MermaidDiagram chart={code} className="my-2" />;
3832
}
3933

40-
// Fallback to regular code block
34+
// Code blocks with syntax highlighting
4135
return (
42-
<code className={className} {...props}>
43-
{children}
44-
</code>
36+
<SyntaxHighlighter code={code} language={language || ''} className="my-2" />
4537
);
4638
};
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
'use client';
2+
3+
import { useEffect, useState, useCallback } from 'react';
4+
import { useTheme } from 'next-themes';
5+
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki';
6+
7+
interface SyntaxHighlighterProps {
8+
code: string;
9+
language: string;
10+
className?: string;
11+
}
12+
13+
// Cache for the highlighter instance
14+
let highlighterCache: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
15+
null;
16+
let highlighterPromise: Promise<
17+
HighlighterGeneric<BundledLanguage, BundledTheme>
18+
> | null = null;
19+
20+
// Common languages to preload for better performance
21+
const COMMON_LANGUAGES: BundledLanguage[] = [
22+
'javascript',
23+
'typescript',
24+
'jsx',
25+
'tsx',
26+
'python',
27+
'java',
28+
'go',
29+
'rust',
30+
'cpp',
31+
'c',
32+
'csharp',
33+
'php',
34+
'ruby',
35+
'swift',
36+
'kotlin',
37+
'scala',
38+
'html',
39+
'css',
40+
'scss',
41+
'json',
42+
'yaml',
43+
'xml',
44+
'markdown',
45+
'bash',
46+
'shell',
47+
'sql',
48+
'dockerfile',
49+
];
50+
51+
const getHighlighter = async () => {
52+
if (highlighterCache) {
53+
return highlighterCache;
54+
}
55+
56+
if (highlighterPromise) {
57+
return highlighterPromise;
58+
}
59+
60+
highlighterPromise = (async () => {
61+
const { createHighlighter } = await import('shiki');
62+
63+
const highlighter = await createHighlighter({
64+
themes: ['github-light', 'github-dark'],
65+
langs: COMMON_LANGUAGES,
66+
});
67+
68+
highlighterCache = highlighter;
69+
return highlighter;
70+
})();
71+
72+
return highlighterPromise;
73+
};
74+
75+
export const SyntaxHighlighter = ({
76+
code,
77+
language,
78+
className,
79+
}: SyntaxHighlighterProps) => {
80+
const [highlightedCode, setHighlightedCode] = useState<string>('');
81+
const [isLoading, setIsLoading] = useState(true);
82+
const [error, setError] = useState<string | null>(null);
83+
const { theme, systemTheme } = useTheme();
84+
85+
const currentTheme = theme === 'system' ? systemTheme : theme;
86+
const shikiTheme = currentTheme === 'dark' ? 'github-dark' : 'github-light';
87+
88+
const highlightCode = useCallback(async () => {
89+
try {
90+
setIsLoading(true);
91+
setError(null);
92+
93+
const highlighter = await getHighlighter();
94+
95+
// Check if the language is supported, fallback to 'text' if not
96+
const supportedLanguages = highlighter.getLoadedLanguages();
97+
const langToUse = supportedLanguages.includes(language as BundledLanguage)
98+
? (language as BundledLanguage)
99+
: 'text';
100+
101+
// If the language isn't loaded yet, try to load it
102+
if (!supportedLanguages.includes(langToUse) && langToUse !== 'text') {
103+
try {
104+
await highlighter.loadLanguage(langToUse);
105+
} catch {
106+
// If loading fails, fall back to text
107+
}
108+
}
109+
110+
const html = highlighter.codeToHtml(code, {
111+
lang: langToUse,
112+
theme: shikiTheme,
113+
transformers: [
114+
{
115+
pre(node) {
116+
// Remove default background and padding since we'll handle it with CSS
117+
if (node.properties.style) {
118+
node.properties.style = (node.properties.style as string)
119+
.replace(/background-color:[^;]+;?/g, '')
120+
.replace(/padding:[^;]+;?/g, '');
121+
}
122+
},
123+
},
124+
],
125+
});
126+
127+
setHighlightedCode(html);
128+
} catch (err) {
129+
console.error('Failed to highlight code:', err);
130+
setError('Failed to highlight code');
131+
} finally {
132+
setIsLoading(false);
133+
}
134+
}, [code, language, shikiTheme]);
135+
136+
useEffect(() => {
137+
highlightCode();
138+
}, [highlightCode]);
139+
140+
if (error) {
141+
// Fallback to plain code if highlighting fails
142+
return (
143+
<pre
144+
className={`bg-muted p-3 rounded overflow-x-auto ${className || ''}`}
145+
>
146+
<code className="bg-transparent p-0 font-mono text-sm">{code}</code>
147+
</pre>
148+
);
149+
}
150+
151+
if (isLoading) {
152+
// Show loading state with plain code
153+
return (
154+
<pre
155+
className={`bg-muted p-3 rounded overflow-x-auto ${className || ''}`}
156+
>
157+
<code className="bg-transparent p-0 font-mono text-sm opacity-70">
158+
{code}
159+
</code>
160+
</pre>
161+
);
162+
}
163+
164+
return (
165+
<div
166+
className={`bg-muted p-3 rounded overflow-x-auto [&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0 ${className || ''}`}
167+
dangerouslySetInnerHTML={{ __html: highlightedCode }}
168+
/>
169+
);
170+
};
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { ThemeProvider } from 'next-themes';
3+
import { CodeBlock } from '../CodeBlock';
4+
5+
// Mock the SyntaxHighlighter component
6+
vi.mock('../SyntaxHighlighter', () => ({
7+
SyntaxHighlighter: ({
8+
code,
9+
language,
10+
className,
11+
}: {
12+
code: string;
13+
language: string;
14+
className?: string;
15+
}) => (
16+
<div
17+
data-testid="syntax-highlighter"
18+
data-language={language}
19+
className={className}
20+
>
21+
{code || ''}
22+
</div>
23+
),
24+
}));
25+
26+
// Mock the MermaidDiagram component
27+
vi.mock('../MermaidDiagram', () => ({
28+
MermaidDiagram: ({
29+
chart,
30+
className,
31+
}: {
32+
chart: string;
33+
className?: string;
34+
}) => (
35+
<div data-testid="mermaid-diagram" className={className}>
36+
{chart}
37+
</div>
38+
),
39+
}));
40+
41+
const renderWithTheme = (component: React.ReactElement) => {
42+
return render(
43+
<ThemeProvider attribute="class" defaultTheme="light">
44+
{component}
45+
</ThemeProvider>,
46+
);
47+
};
48+
49+
describe('CodeBlock', () => {
50+
it('renders inline code as regular code element', () => {
51+
renderWithTheme(<CodeBlock>const x = 1;</CodeBlock>);
52+
53+
const codeElement = screen.getByText(/const/).closest('code');
54+
expect(codeElement?.tagName).toBe('CODE');
55+
});
56+
57+
it('renders mermaid code blocks using MermaidDiagram component', () => {
58+
const mermaidCode = `graph TD
59+
A[Start] --> B{Is it working?}`;
60+
61+
renderWithTheme(
62+
<CodeBlock className="language-mermaid">{mermaidCode}</CodeBlock>,
63+
);
64+
65+
expect(screen.getByTestId('mermaid-diagram')).toBeInTheDocument();
66+
});
67+
68+
it('renders code blocks with language using SyntaxHighlighter', () => {
69+
renderWithTheme(
70+
<CodeBlock className="language-javascript">
71+
console.log(&apos;test&apos;);
72+
</CodeBlock>,
73+
);
74+
75+
const syntaxHighlighter = screen.getByTestId('syntax-highlighter');
76+
expect(syntaxHighlighter).toBeInTheDocument();
77+
expect(syntaxHighlighter).toHaveAttribute('data-language', 'javascript');
78+
});
79+
80+
it('renders text language using SyntaxHighlighter', () => {
81+
renderWithTheme(
82+
<CodeBlock className="language-text">This is plain text</CodeBlock>,
83+
);
84+
85+
const syntaxHighlighter = screen.getByTestId('syntax-highlighter');
86+
expect(syntaxHighlighter).toBeInTheDocument();
87+
expect(syntaxHighlighter).toHaveAttribute('data-language', 'text');
88+
});
89+
90+
it('renders inline code when no language is specified', () => {
91+
renderWithTheme(<CodeBlock>Some code without language</CodeBlock>);
92+
93+
const codeElement = screen
94+
.getByText(/Some code without language/)
95+
.closest('code');
96+
expect(codeElement).toBeInTheDocument();
97+
expect(codeElement?.tagName).toBe('CODE');
98+
});
99+
});

0 commit comments

Comments
 (0)