Skip to content

Commit b6a2d5a

Browse files
authored
feat: use shiki for syntax highlight (#3052)
1 parent c5f519a commit b6a2d5a

File tree

11 files changed

+951
-492
lines changed

11 files changed

+951
-492
lines changed

package-lock.json

Lines changed: 580 additions & 236 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@
5252
"react-redux": "^9.2.0",
5353
"react-router-dom": "^5.3.4",
5454
"react-split": "^2.0.14",
55-
"react-syntax-highlighter": "^15.6.1",
5655
"redux": "^5.0.1",
5756
"redux-location-state": "^2.8.2",
57+
"shiki": "^3.15.0",
5858
"tslib": "^2.8.1",
5959
"use-query-params": "^2.2.1",
6060
"uuid": "^10.0.0",
@@ -143,7 +143,6 @@
143143
"@types/react": "^18.3.18",
144144
"@types/react-dom": "^18.3.5",
145145
"@types/react-router-dom": "^5.3.3",
146-
"@types/react-syntax-highlighter": "^15.5.13",
147146
"@types/uuid": "^10.0.0",
148147
"@typescript-eslint/parser": "^8.34.1",
149148
"copyfiles": "^2.4.1",

src/components/SyntaxHighlighter/YDBSyntaxHighlighter.scss

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,28 @@
2626
}
2727
}
2828

29+
&__content {
30+
overflow: auto;
31+
32+
height: 100%;
33+
34+
background-color: var(--g-color-base-misc-light);
35+
scrollbar-color: var(--g-color-scroll-handle) transparent;
36+
37+
pre {
38+
margin: 0;
39+
padding: var(--g-spacing-4) 0 var(--g-spacing-4) var(--g-spacing-4);
40+
41+
background: transparent !important;
42+
}
43+
44+
code {
45+
white-space: pre-wrap;
46+
word-break: break-word;
47+
@include mixins.text-code-2();
48+
}
49+
}
50+
2951
.data-table__row:hover &__copy,
3052
.ydb-paginated-table__row:hover &__copy {
3153
opacity: 1;
Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,16 @@
11
import React from 'react';
22

3-
import {nanoid} from '@reduxjs/toolkit';
4-
import {PrismLight as ReactSyntaxHighlighter} from 'react-syntax-highlighter';
3+
import {useThemeType} from '@gravity-ui/uikit';
54

65
import type {ClipboardButtonProps} from '../ClipboardButton/ClipboardButton';
76
import {ClipboardButton} from '../ClipboardButton/ClipboardButton';
87

98
import {b} from './shared';
10-
import {useSyntaxHighlighterStyle} from './themes';
9+
import {highlightCode} from './shikiHighlighter';
1110
import type {Language} from './types';
12-
import {yql} from './yql';
1311

1412
import './YDBSyntaxHighlighter.scss';
1513

16-
async function registerLanguage(lang: Language) {
17-
if (lang === 'yql') {
18-
ReactSyntaxHighlighter.registerLanguage('yql', yql);
19-
} else {
20-
const {default: syntax} = await import(
21-
`react-syntax-highlighter/dist/esm/languages/prism/${lang}`
22-
);
23-
ReactSyntaxHighlighter.registerLanguage(lang, syntax);
24-
}
25-
}
26-
2714
export interface WithClipboardButtonProp extends ClipboardButtonProps {
2815
alwaysVisible?: boolean;
2916
}
@@ -43,17 +30,36 @@ export function YDBSyntaxHighlighter({
4330
transparentBackground = true,
4431
withClipboardButton,
4532
}: YDBSyntaxHighlighterProps) {
46-
const [highlighterKey, setHighlighterKey] = React.useState('');
33+
const [highlightedHtml, setHighlightedHtml] = React.useState<string>('');
34+
const [isLoading, setIsLoading] = React.useState(true);
4735

48-
const style = useSyntaxHighlighterStyle(transparentBackground);
36+
const themeType = useThemeType();
4937

5038
React.useEffect(() => {
51-
async function registerLangAndUpdateKey() {
52-
await registerLanguage(language);
53-
setHighlighterKey(nanoid());
39+
let cancelled = false;
40+
41+
async function highlight() {
42+
setIsLoading(true);
43+
try {
44+
const html = await highlightCode(text, language, themeType);
45+
if (!cancelled) {
46+
setHighlightedHtml(html);
47+
}
48+
} catch (error) {
49+
console.error('Failed to highlight code:', error);
50+
} finally {
51+
if (!cancelled) {
52+
setIsLoading(false);
53+
}
54+
}
5455
}
55-
registerLangAndUpdateKey();
56-
}, [language]);
56+
57+
highlight();
58+
59+
return () => {
60+
cancelled = true;
61+
};
62+
}, [text, language, themeType]);
5763

5864
const renderCopyButton = () => {
5965
if (!withClipboardButton) {
@@ -75,27 +81,42 @@ export function YDBSyntaxHighlighter({
7581
};
7682

7783
let paddingStyles = {};
78-
7984
if (withClipboardButton?.alwaysVisible) {
80-
if (withClipboardButton.withLabel) {
81-
paddingStyles = {paddingRight: 80};
82-
} else {
85+
if (withClipboardButton.withLabel === false) {
8386
paddingStyles = {paddingRight: 40};
87+
} else {
88+
paddingStyles = {paddingRight: 80};
8489
}
8590
}
8691

92+
const containerStyle: React.CSSProperties = {
93+
...paddingStyles,
94+
};
95+
96+
if (transparentBackground) {
97+
containerStyle.background = 'transparent';
98+
}
99+
87100
return (
88101
<div className={b(null, className)}>
89102
{renderCopyButton()}
90103

91-
<ReactSyntaxHighlighter
92-
key={highlighterKey}
93-
language={language}
94-
style={style}
95-
customStyle={{height: '100%', ...paddingStyles}}
96-
>
97-
{text}
98-
</ReactSyntaxHighlighter>
104+
{isLoading || !highlightedHtml ? (
105+
<div
106+
style={containerStyle}
107+
className={b('content', {transparent: transparentBackground})}
108+
>
109+
<pre>
110+
<code>{text}</code>
111+
</pre>
112+
</div>
113+
) : (
114+
<div
115+
className={b('content', {transparent: transparentBackground})}
116+
style={containerStyle}
117+
dangerouslySetInnerHTML={{__html: highlightedHtml}}
118+
/>
119+
)}
99120
</div>
100121
);
101122
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type {Highlighter} from 'shiki';
2+
3+
import {yqlDarkTheme, yqlLightTheme} from './themes';
4+
import type {Language, Theme} from './types';
5+
6+
import yqlGrammar from 'monaco-yql-languages/build/yql/YQL.tmLanguage.json';
7+
8+
// Custom themes for YQL
9+
const YQL_LIGHT_THEME = 'yql-light';
10+
const YQL_DARK_THEME = 'yql-dark';
11+
12+
// Standard themes for other languages
13+
const STANDARD_LIGHT_THEME = 'github-light';
14+
const STANDARD_DARK_THEME = 'github-dark';
15+
16+
// Cache the highlighter promise to prevent multiple instances
17+
let highlighterPromise: Promise<Highlighter> | null = null;
18+
19+
// Track what's already loaded
20+
const loadedLanguages = new Set<Language>();
21+
const loadedThemes = new Set<Theme>();
22+
23+
/**
24+
* Get or create the single highlighter instance
25+
* Lazy loads the shiki library on first use
26+
*/
27+
async function getHighlighter(): Promise<Highlighter> {
28+
if (!highlighterPromise) {
29+
// Dynamically import shiki library only when needed
30+
highlighterPromise = (async () => {
31+
const {createHighlighter} = await import('shiki');
32+
return createHighlighter({
33+
themes: [],
34+
langs: [],
35+
});
36+
})();
37+
}
38+
return highlighterPromise;
39+
}
40+
41+
/**
42+
* Ensure language is loaded into the highlighter
43+
*/
44+
async function ensureLanguageLoaded(lang: Language): Promise<void> {
45+
if (loadedLanguages.has(lang)) {
46+
return;
47+
}
48+
49+
const hl = await getHighlighter();
50+
51+
try {
52+
if (lang === 'yql') {
53+
await hl.loadLanguage(yqlGrammar);
54+
} else {
55+
await hl.loadLanguage(lang);
56+
}
57+
loadedLanguages.add(lang);
58+
} catch (error) {
59+
console.error(`Failed to load language: ${lang}`, error);
60+
throw error;
61+
}
62+
}
63+
64+
/**
65+
* Ensure theme is loaded into the highlighter
66+
*/
67+
async function ensureThemeLoaded(themeName: Theme): Promise<void> {
68+
if (loadedThemes.has(themeName)) {
69+
return;
70+
}
71+
72+
const hl = await getHighlighter();
73+
74+
try {
75+
if (themeName === YQL_LIGHT_THEME) {
76+
await hl.loadTheme(yqlLightTheme);
77+
} else if (themeName === YQL_DARK_THEME) {
78+
await hl.loadTheme(yqlDarkTheme);
79+
} else {
80+
await hl.loadTheme(themeName);
81+
}
82+
loadedThemes.add(themeName);
83+
} catch (error) {
84+
console.error(`Failed to load theme: ${themeName}`, error);
85+
throw error;
86+
}
87+
}
88+
89+
/**
90+
* Highlight code with Shiki
91+
* Uses a single highlighter instance with on-demand loading of languages and themes
92+
*/
93+
export async function highlightCode(
94+
code: string,
95+
lang: Language,
96+
theme: 'light' | 'dark',
97+
): Promise<string> {
98+
// Determine theme name
99+
const isYql = lang === 'yql';
100+
const isDark = theme === 'dark';
101+
102+
let themeName: Theme = isDark ? STANDARD_DARK_THEME : STANDARD_LIGHT_THEME;
103+
if (isYql) {
104+
themeName = isDark ? YQL_DARK_THEME : YQL_LIGHT_THEME;
105+
}
106+
107+
// Load language and theme if needed
108+
await Promise.all([ensureLanguageLoaded(lang), ensureThemeLoaded(themeName)]);
109+
110+
const hl = await getHighlighter();
111+
112+
return hl.codeToHtml(code, {
113+
lang,
114+
theme: themeName,
115+
});
116+
}

0 commit comments

Comments
 (0)