Skip to content

Commit 928c69c

Browse files
committed
feat: Add optional line numbers with showLineNumbers option (#61)
Add support for displaying line numbers alongside code blocks with customizable styling and starting line number.
1 parent 9179d63 commit 928c69c

File tree

12 files changed

+346
-29
lines changed

12 files changed

+346
-29
lines changed

.changeset/curly-mammals-allow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-shiki": patch
3+
---
4+
5+
feat: Add support for line numbers with `showLineNumbers`

package/README.md

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ A performant client-side syntax highlighting component and hook for React, built
2323
- [Custom Languages](#custom-languages)
2424
- [Preloading Custom Languages](#preloading-custom-languages)
2525
- [Custom Transformers](#custom-transformers)
26+
- [Line Numbers](#line-numbers)
2627
- [Integration](#integration)
2728
- [Integration with react-markdown](#integration-with-react-markdown)
2829
- [Handling Inline Code](#handling-inline-code)
@@ -44,6 +45,7 @@ A performant client-side syntax highlighting component and hook for React, built
4445
- 🖥️ `ShikiHighlighter` component displays a language label for each code block
4546
when `showLanguage` is set to `true` (default)
4647
- 🎨 Customizable styling of generated code blocks and language labels
48+
- 📏 Optional line numbers with customizable starting number and styling
4749

4850
## Installation
4951

@@ -155,6 +157,8 @@ See [Shiki - RegExp Engines](https://shiki.style/guide/regex-engines) for more i
155157
| `theme` | `string \| object` | `'github-dark'` | Single or multi-theme configuration, built-in or custom textmate theme object |
156158
| `delay` | `number` | `0` | Delay between highlights (in milliseconds) |
157159
| `customLanguages` | `array` | `[]` | Array of custom languages to preload |
160+
| `showLineNumbers` | `boolean` | `false` | Display line numbers alongside code |
161+
| `startingLineNumber` | `number` | `1` | Starting line number when line numbers are enabled |
158162
| `transformers` | `array` | `[]` | Custom Shiki transformers for modifying the highlighting output |
159163
| `cssVariablePrefix` | `string` | `'--shiki'` | Prefix for CSS variables storing theme colors |
160164
| `defaultColor` | `string \| false` | `'light'` | Default theme mode when using multiple themes, can also disable default theme |
@@ -221,12 +225,12 @@ Custom themes can be passed as a TextMate theme in JavaScript object. For exampl
221225
```tsx
222226
import tokyoNight from "../styles/tokyo-night.json";
223227

224-
// Using the component
228+
// Component
225229
<ShikiHighlighter language="tsx" theme={tokyoNight}>
226230
{code.trim()}
227231
</ShikiHighlighter>
228232

229-
// Using the hook
233+
// Hook
230234
const highlightedCode = useShikiHighlighter(code, "tsx", tokyoNight);
231235
```
232236

@@ -237,12 +241,12 @@ Custom languages should be passed as a TextMate grammar in JavaScript object. Fo
237241
```tsx
238242
import mcfunction from "../langs/mcfunction.tmLanguage.json";
239243

240-
// Using the component
244+
// Component
241245
<ShikiHighlighter language={mcfunction} theme="github-dark">
242246
{code.trim()}
243247
</ShikiHighlighter>
244248

245-
// Using the hook
249+
// Hook
246250
const highlightedCode = useShikiHighlighter(code, mcfunction, "github-dark");
247251
```
248252

@@ -254,7 +258,7 @@ For dynamic highlighting scenarios where language selection happens at runtime:
254258
import mcfunction from "../langs/mcfunction.tmLanguage.json";
255259
import bosque from "../langs/bosque.tmLanguage.json";
256260

257-
// With the component
261+
// Component
258262
<ShikiHighlighter
259263
language="typescript"
260264
theme="github-dark"
@@ -263,7 +267,7 @@ import bosque from "../langs/bosque.tmLanguage.json";
263267
{code.trim()}
264268
</ShikiHighlighter>
265269

266-
// With the hook
270+
// Hook
267271
const highlightedCode = useShikiHighlighter(code, "typescript", "github-dark", {
268272
customLanguages: [mcfunction, bosque],
269273
});
@@ -274,17 +278,82 @@ const highlightedCode = useShikiHighlighter(code, "typescript", "github-dark", {
274278
```tsx
275279
import { customTransformer } from "../utils/shikiTransformers";
276280

277-
// Using the component
281+
// Component
278282
<ShikiHighlighter language="tsx" transformers={[customTransformer]}>
279283
{code.trim()}
280284
</ShikiHighlighter>
281285

282-
// Using the hook
286+
// Hook
283287
const highlightedCode = useShikiHighlighter(code, "tsx", "github-dark", {
284288
transformers: [customTransformer],
285289
});
286290
```
287291

292+
### Line Numbers
293+
294+
Display line numbers alongside your code, these are CSS-based
295+
and can be customized with CSS variables:
296+
297+
```tsx
298+
// Component
299+
<ShikiHighlighter
300+
language="javascript"
301+
theme="github-dark"
302+
showLineNumbers,
303+
startingLineNumber={0} // default is 1
304+
>
305+
{code}
306+
</ShikiHighlighter>
307+
308+
<ShikiHighlighter
309+
language="python"
310+
theme="github-dark"
311+
showLineNumbers
312+
startingLineNumber={0}
313+
>
314+
{code}
315+
</ShikiHighlighter>
316+
317+
// Hook (import 'react-shiki/css' for line numbers to work)
318+
const highlightedCode = useShikiHighlighter(code, "javascript", "github-dark", {
319+
showLineNumbers: true,
320+
startingLineNumber: 0,
321+
});
322+
```
323+
324+
> [!NOTE]
325+
> When using the hook with line numbers, import the CSS file for the line numbers to work:
326+
> ```tsx
327+
> import 'react-shiki/css';
328+
> ```
329+
> Or provide your own CSS counter implementation and styles for `.line-numbers` (line `span`) and `.has-line-numbers` (container `code` element)
330+
331+
Available CSS variables for customization:
332+
```css
333+
--line-numbers-foreground: rgba(107, 114, 128, 0.5);
334+
--line-numbers-width: 2ch;
335+
--line-numbers-padding-left: 0ch;
336+
--line-numbers-padding-right: 2ch;
337+
--line-numbers-font-size: inherit;
338+
--line-numbers-font-weight: inherit;
339+
--line-numbers-opacity: 1;
340+
```
341+
342+
You can customize them in your own CSS or by using the style prop on the component:
343+
```tsx
344+
<ShikiHighlighter
345+
language="javascript"
346+
theme="github-dark"
347+
showLineNumbers
348+
style={{
349+
'--line-numbers-foreground': '#60a5fa',
350+
'--line-numbers-width': '3ch'
351+
}}
352+
>
353+
{code}
354+
</ShikiHighlighter>
355+
```
356+
288357
## Integration
289358

290359
### Integration with react-markdown

package/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
"type": "module",
2727
"main": "./dist/index.js",
2828
"types": "./dist/index.d.ts",
29-
"files": [
30-
"dist"
31-
],
29+
"files": ["dist", "src/lib/styles.css"],
3230
"exports": {
3331
".": {
3432
"types": "./dist/index.d.ts",
@@ -41,7 +39,8 @@
4139
"./core": {
4240
"types": "./dist/core.d.ts",
4341
"default": "./dist/core.js"
44-
}
42+
},
43+
"./css": "./src/lib/styles.css"
4544
},
4645
"scripts": {
4746
"dev": "tsup --watch",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { render } from '@testing-library/react';
3+
import { ShikiHighlighter } from '../index';
4+
5+
describe('Line Numbers', () => {
6+
const code = `function test() {
7+
return 'hello';
8+
}`;
9+
10+
it('should not show line numbers by default', async () => {
11+
const { container } = render(
12+
<ShikiHighlighter language="javascript" theme="github-dark">
13+
{code}
14+
</ShikiHighlighter>
15+
);
16+
17+
// Wait for highlighting to complete
18+
await new Promise((resolve) => setTimeout(resolve, 100));
19+
20+
const container_element = container.querySelector('#shiki-container');
21+
expect(container_element).not.toHaveClass('has-line-numbers');
22+
23+
const lineElements = container.querySelectorAll('.line-numbers');
24+
expect(lineElements).toHaveLength(0);
25+
});
26+
27+
it('should show line numbers when enabled', async () => {
28+
const { container } = render(
29+
<ShikiHighlighter
30+
language="javascript"
31+
theme="github-dark"
32+
showLineNumbers
33+
>
34+
{code}
35+
</ShikiHighlighter>
36+
);
37+
38+
// Wait for highlighting to complete
39+
await new Promise((resolve) => setTimeout(resolve, 100));
40+
41+
const codeElement = container.querySelector('code');
42+
expect(codeElement).toHaveClass('has-line-numbers');
43+
44+
const lineElements = container.querySelectorAll('.line-numbers');
45+
expect(lineElements.length).toBeGreaterThan(0);
46+
});
47+
48+
it('should set custom starting line number', async () => {
49+
const { container } = render(
50+
<ShikiHighlighter
51+
language="javascript"
52+
theme="github-dark"
53+
showLineNumbers
54+
startingLineNumber={42}
55+
>
56+
{code}
57+
</ShikiHighlighter>
58+
);
59+
60+
// Wait for highlighting to complete
61+
await new Promise((resolve) => setTimeout(resolve, 100));
62+
63+
// Check which elements have the style attribute
64+
const elementsWithStyle = container.querySelectorAll(
65+
'[style*="--line-start"]'
66+
);
67+
expect(elementsWithStyle.length).toBeGreaterThan(0);
68+
expect(elementsWithStyle[0]?.getAttribute('style')).toContain(
69+
'--line-start: 42'
70+
);
71+
});
72+
73+
it('should not set line-start CSS variable when starting from 1', async () => {
74+
const { container } = render(
75+
<ShikiHighlighter
76+
language="javascript"
77+
theme="github-dark"
78+
showLineNumbers
79+
startingLineNumber={1}
80+
>
81+
{code}
82+
</ShikiHighlighter>
83+
);
84+
85+
// Wait for highlighting to complete
86+
await new Promise((resolve) => setTimeout(resolve, 100));
87+
88+
const elementsWithStyle = container.querySelectorAll(
89+
'[style*="--line-start"]'
90+
);
91+
expect(elementsWithStyle.length).toBe(0);
92+
});
93+
});

package/src/__tests__/performance.bench.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
1919
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
2020
import htmlReactParser from 'html-react-parser';
2121

22-
import type { Language, Theme, Themes } from '../types';
22+
import type { Language, Theme, Themes } from '../lib/types';
2323

24-
import { resolveLanguage, resolveTheme } from '../resolvers';
24+
import { resolveLanguage, resolveTheme } from '../lib/resolvers';
2525
// --- Test Data ---
2626

2727
// Small code sample (few lines)

package/src/lib/component.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ export interface ShikiHighlighterProps extends HighlighterOptions {
7272
*/
7373
showLanguage?: boolean;
7474

75+
/**
76+
* Whether to show line numbers
77+
* @default false
78+
*/
79+
showLineNumbers?: boolean;
80+
81+
/**
82+
* Starting line number (when showLineNumbers is true)
83+
* @default 1
84+
*/
85+
startingLineNumber?: number;
86+
7587
/**
7688
* The HTML element that wraps the generated code block.
7789
* @default 'pre'
@@ -104,6 +116,8 @@ export const createShikiHighlighterComponent = (
104116
className,
105117
langClassName,
106118
showLanguage = true,
119+
showLineNumbers = false,
120+
startingLineNumber = 1,
107121
children: code,
108122
as: Element = 'pre',
109123
customLanguages,
@@ -115,6 +129,8 @@ export const createShikiHighlighterComponent = (
115129
customLanguages,
116130
defaultColor,
117131
cssVariablePrefix,
132+
showLineNumbers,
133+
startingLineNumber,
118134
...shikiOptions,
119135
};
120136

package/src/lib/hook.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131

3232
import { throttleHighlighting } from './utils';
3333
import { resolveLanguage, resolveTheme } from './resolvers';
34+
import { lineNumbersTransformer } from './transformers';
3435

3536
const DEFAULT_THEMES: Themes = {
3637
light: 'github-light',
@@ -108,9 +109,14 @@ export const useShikiHighlighter = (
108109
});
109110

110111
const shikiOptions = useMemo<CodeToHastOptions>(() => {
111-
const { defaultColor, cssVariablePrefix, ...restOptions } =
112-
stableOpts;
113112
const languageOption = { lang: languageId };
113+
const {
114+
defaultColor,
115+
cssVariablePrefix,
116+
showLineNumbers,
117+
startingLineNumber,
118+
...restOptions
119+
} = stableOpts;
114120

115121
const themeOptions = isMultiTheme
116122
? ({
@@ -122,7 +128,18 @@ export const useShikiHighlighter = (
122128
theme: singleTheme || DEFAULT_THEMES.dark,
123129
} as CodeOptionsSingleTheme);
124130

125-
return { ...languageOption, ...themeOptions, ...restOptions };
131+
// Add line numbers transformer if enabled
132+
const transformers = restOptions.transformers || [];
133+
if (showLineNumbers) {
134+
transformers.push(lineNumbersTransformer(startingLineNumber));
135+
}
136+
137+
return {
138+
...languageOption,
139+
...themeOptions,
140+
...restOptions,
141+
transformers,
142+
};
126143
}, [languageId, themeId, langRev, themeRev, optsRev]);
127144

128145
useEffect(() => {
@@ -141,9 +158,11 @@ export const useShikiHighlighter = (
141158

142159
// Check if language is loaded, fallback to plaintext if not
143160
const loadedLanguages = highlighter.getLoadedLanguages();
144-
const langToUse = loadedLanguages.includes(languageId) ? languageId : 'plaintext';
161+
const langToUse = loadedLanguages.includes(languageId)
162+
? languageId
163+
: 'plaintext';
145164
const finalOptions = { ...shikiOptions, lang: langToUse };
146-
165+
147166
const hast = highlighter.codeToHast(code, finalOptions);
148167

149168
if (isMounted) {

package/src/lib/resolvers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const resolveLanguage = (
7171
};
7272
}
7373

74-
// For any other string, pass it through,
74+
// For any other string, pass it through,
7575
// fallback is handled in highlighter factories
7676
return {
7777
languageId: lang,

0 commit comments

Comments
 (0)