Skip to content

Commit 04fdad3

Browse files
committed
feat: add outputFormat: 'tokens' support
hook only and experimental - no renderer yet
1 parent 2cd48c1 commit 04fdad3

10 files changed

Lines changed: 182 additions & 73 deletions

File tree

.changeset/token-output-format.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-shiki": minor
3+
---
4+
5+
Add hook-only `outputFormat: "tokens"` support for returning Shiki token output.

package/README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ See [Shiki - RegExp Engines](https://shiki.style/guide/regex-engines) for more i
220220
| `transformers` | `array` | `[]` | Custom Shiki transformers for modifying the highlighting output |
221221
| `cssVariablePrefix` | `string` | `'--shiki'` | Prefix for CSS variables storing theme colors |
222222
| `defaultColor` | `string \| false` | `'light'` | Default theme mode when using multiple themes, can also disable default theme |
223-
| `outputFormat` | `string` | `'react'` | Output format: 'react' for React nodes, 'html' for HTML string |
223+
| `outputFormat` | `string` | `'react'` | Output format: 'react' for React nodes, 'html' for HTML string, or 'tokens' for Shiki tokens in the hook |
224224
| `tabindex` | `number` | `0` | Tab index for the code block |
225225
| `decorations` | `array` | `[]` | Custom decorations to wrap the highlighted tokens with |
226226
| `structure` | `string` | `classic` | The structure of the generated HAST and HTML - `classic` or `inline` |
@@ -631,7 +631,7 @@ const highlightedCode = useShikiHighlighter(code, "tsx", "github-dark", {
631631

632632
### Output Format Optimization
633633

634-
`react-shiki` provides two output formats to balance safety and performance:
634+
`react-shiki` provides output formats to balance safety, performance, and custom rendering:
635635

636636
**React Nodes (Default)** - Safer, no `dangerouslySetInnerHTML` required
637637
```tsx
@@ -657,7 +657,24 @@ const highlightedCode = useShikiHighlighter(code, "tsx", "github-dark", {
657657
</ShikiHighlighter>
658658
```
659659

660-
Choose HTML output when performance is critical and you trust the code source. Use the default React output when handling untrusted content or when security is the primary concern.
660+
**Shiki Tokens** - hook-only output for custom renderers
661+
```tsx
662+
const highlighted = useShikiHighlighter(code, "tsx", "github-dark", {
663+
outputFormat: "tokens",
664+
});
665+
666+
const rendered = highlighted?.tokens.map((line, i) => (
667+
<div key={i}>
668+
{line.map((token, j) => (
669+
<span key={j} style={{ color: token.color }}>
670+
{token.content}
671+
</span>
672+
))}
673+
</div>
674+
));
675+
```
676+
677+
Choose HTML output when performance is critical and you trust the code source. Use the default React output when handling untrusted content or when security is the primary concern. Use token output when you need to own rendering yourself; it is intentionally available on the hook, not the `ShikiHighlighter` component.
661678

662679
---
663680

package/src/core.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { useHighlight } from './lib/hook';
22
import { validateCoreHighlighter } from './bundles/core';
3-
import type { UseShikiHighlighter } from './lib/types';
3+
import type {
4+
BaseHighlighterOptions,
5+
Language,
6+
OutputFormat,
7+
Theme,
8+
Themes,
9+
UseShikiHighlighter,
10+
} from './lib/types';
411

512
export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins';
613

@@ -62,16 +69,18 @@ export type { LanguageRegistration } from 'shiki/core';
6269
*
6370
* Core bundle (minimal). For plug-and-play: `react-shiki` or `react-shiki/web`
6471
*/
65-
export const useShikiHighlighter: UseShikiHighlighter = (
66-
code,
67-
lang,
68-
themeInput,
69-
options = {}
72+
export const useShikiHighlighter: UseShikiHighlighter = <
73+
F extends OutputFormat = 'react',
74+
>(
75+
code: string,
76+
lang: Language,
77+
themeInput: Theme | Themes,
78+
options: BaseHighlighterOptions & { outputFormat?: F } = {}
7079
) => {
7180
// Validate that highlighter is provided
7281
const highlighter = validateCoreHighlighter(options.highlighter);
7382

74-
return useHighlight(
83+
return useHighlight<F>(
7584
code,
7685
lang,
7786
themeInput,

package/src/index.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useHighlight } from './lib/hook';
1+
import { createUseShikiHighlighter } from './lib/hook';
22
import { createFullHighlighter } from './bundles/full';
33
import type { UseShikiHighlighter } from './lib/types';
44

@@ -50,20 +50,8 @@ export type { LanguageRegistration } from 'shiki';
5050
*
5151
* Full bundle (~6.4MB minified, 1.2MB gzipped). For smaller bundles: `react-shiki/web` or `react-shiki/core`
5252
*/
53-
export const useShikiHighlighter: UseShikiHighlighter = (
54-
code,
55-
lang,
56-
themeInput,
57-
options = {}
58-
) => {
59-
return useHighlight(
60-
code,
61-
lang,
62-
themeInput,
63-
options,
64-
createFullHighlighter
65-
);
66-
};
53+
export const useShikiHighlighter: UseShikiHighlighter =
54+
createUseShikiHighlighter(createFullHighlighter);
6755

6856
/**
6957
* ShikiHighlighter component using the full bundle.

package/src/lib/component.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import '../styles/features.css';
33
import { clsx } from 'clsx';
44

55
import type {
6-
HighlighterOptions,
6+
ComponentHighlighterOptions,
7+
ComponentOutputFormat,
78
Language,
89
Theme,
910
Themes,
@@ -14,7 +15,8 @@ import { forwardRef } from 'react';
1415
/**
1516
* Props for the ShikiHighlighter component
1617
*/
17-
export interface ShikiHighlighterProps extends HighlighterOptions {
18+
export interface ShikiHighlighterProps
19+
extends ComponentHighlighterOptions {
1820
/**
1921
* The programming language for syntax highlighting
2022
* Supports custom textmate grammar objects in addition to Shiki's bundled languages
@@ -126,7 +128,7 @@ export const createShikiHighlighterComponent = (
126128
ref
127129
) => {
128130
// Destructure some options for use in hook
129-
const options: HighlighterOptions = {
131+
const options: ComponentHighlighterOptions = {
130132
delay,
131133
transformers,
132134
customLanguages,
@@ -138,12 +140,21 @@ export const createShikiHighlighterComponent = (
138140
...shikiOptions,
139141
};
140142

143+
if (
144+
(options as { outputFormat?: string }).outputFormat === 'tokens'
145+
) {
146+
throw new Error(
147+
'[react-shiki] outputFormat: "tokens" is hook-only. ' +
148+
'Use useShikiHighlighter directly to render tokens.'
149+
);
150+
}
151+
141152
const displayLanguageId =
142153
typeof language === 'object'
143154
? language.name || null
144155
: language?.trim() || null;
145156

146-
const highlightedCode = useShikiHighlighterImpl(
157+
const highlightedCode = useShikiHighlighterImpl<ComponentOutputFormat>(
147158
code,
148159
language,
149160
theme,
@@ -167,10 +178,7 @@ export const createShikiHighlighterComponent = (
167178
<span
168179
id="language-label"
169180
data-slot="language-label"
170-
className={clsx(
171-
'rs-language-label',
172-
langClassName
173-
)}
181+
className={clsx('rs-language-label', langClassName)}
174182
style={langStyle}
175183
>
176184
{displayLanguageId}

package/src/lib/hook.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
44
import type { CodeToHastOptions } from 'shiki';
55

66
import type {
7-
HighlightedCode,
7+
BaseHighlighterOptions,
88
HighlighterFactory,
99
HighlighterOptions,
10+
HighlightResultMap,
1011
Language,
12+
OutputFormat,
1113
Theme,
1214
Themes,
1315
TimeoutState,
16+
UseShikiHighlighter,
1417
} from './types';
1518

1619
import { throttleHighlighting, useStableValue } from './utils';
@@ -22,22 +25,22 @@ import {
2225
import { resolveTheme } from './theme';
2326
import { buildShikiOptions } from './options';
2427

25-
export async function highlight(
28+
export async function highlight<F extends OutputFormat = 'react'>(
2629
code: string,
2730
resolved: {
2831
languageId: string;
2932
langsToLoad: Language[];
3033
themesToLoad: Theme[];
3134
shikiOptions: CodeToHastOptions;
3235
},
33-
opts: Pick<
34-
HighlighterOptions,
35-
'highlighter' | 'outputFormat' | 'engine'
36-
>,
36+
opts: Pick<HighlighterOptions, 'highlighter' | 'engine'> & {
37+
outputFormat?: F;
38+
},
3739
factory: HighlighterFactory
38-
) {
40+
): Promise<HighlightResultMap[F]> {
3941
const { languageId, langsToLoad, themesToLoad, shikiOptions } =
4042
resolved;
43+
const format: OutputFormat = opts.outputFormat ?? 'react';
4144

4245
const highlighter =
4346
opts.highlighter ??
@@ -55,24 +58,30 @@ export async function highlight(
5558
);
5659
const options = { ...shikiOptions, lang: language };
5760

58-
return opts.outputFormat === 'html'
59-
? highlighter.codeToHtml(code, options)
60-
: toJsxRuntime(highlighter.codeToHast(code, options), {
61+
const outputs: { [K in OutputFormat]: () => HighlightResultMap[K] } = {
62+
react: () =>
63+
toJsxRuntime(highlighter.codeToHast(code, options), {
6164
jsx,
6265
jsxs,
6366
Fragment,
64-
});
67+
}),
68+
html: () => highlighter.codeToHtml(code, options),
69+
tokens: () => highlighter.codeToTokens(code, options),
70+
};
71+
72+
return outputs[format]() as HighlightResultMap[F];
6573
}
6674

67-
export const useHighlight = (
75+
export const useHighlight = <F extends OutputFormat = 'react'>(
6876
code: string,
6977
lang: Language,
7078
themeInput: Theme | Themes,
71-
options: HighlighterOptions = {},
79+
options: BaseHighlighterOptions & { outputFormat?: F } = {},
7280
highlighterFactory: HighlighterFactory
73-
) => {
74-
const [highlightedCode, setHighlightedCode] =
75-
useState<HighlightedCode>(null);
81+
): HighlightResultMap[F] | null => {
82+
const [highlightedCode, setHighlightedCode] = useState<
83+
HighlightResultMap[F] | null
84+
>(null);
7685

7786
const stableLang = useStableValue(lang);
7887
const stableTheme = useStableValue(themeInput);
@@ -111,7 +120,7 @@ export const useHighlight = (
111120

112121
const run = async () => {
113122
try {
114-
const result = await highlight(
123+
const result = await highlight<F>(
115124
code,
116125
resolved,
117126
stableOpts,
@@ -138,3 +147,18 @@ export const useHighlight = (
138147

139148
return highlightedCode;
140149
};
150+
151+
/**
152+
* Create a `useShikiHighlighter` hook bound to a specific highlighter factory.
153+
* Used by the bundled entry points (full / web / core).
154+
*/
155+
export const createUseShikiHighlighter = (
156+
factory: HighlighterFactory
157+
): UseShikiHighlighter => {
158+
return <F extends OutputFormat = 'react'>(
159+
code: string,
160+
lang: Language,
161+
themeInput: Theme | Themes,
162+
options?: BaseHighlighterOptions & { outputFormat?: F }
163+
) => useHighlight<F>(code, lang, themeInput, options, factory);
164+
};

package/src/lib/theme.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ export type ResolvedTheme =
1010
| { isMulti: true; themes: Themes; themesToLoad: Theme[] }
1111
| { isMulti: false; theme: Theme; themesToLoad: Theme[] };
1212

13-
const isTextmateTheme = (value: unknown): value is ThemeRegistrationAny => {
13+
const isTextmateTheme = (
14+
value: unknown
15+
): value is ThemeRegistrationAny => {
1416
if (typeof value !== 'object' || value === null) return false;
1517
const v = value as Partial<ThemeRegistrationAny> & {
1618
settings?: unknown;

package/src/lib/types.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
SpecialLanguage,
1313
StringLiteralUnion,
1414
ThemeRegistrationAny,
15+
TokensResult,
1516
} from 'shiki';
1617

1718
import type { ReactElement } from 'react';
@@ -87,9 +88,10 @@ interface ReactShikiOptions {
8788
* Output format for the highlighted code.
8889
* - 'react': Returns React nodes (default, safer)
8990
* - 'html': Returns HTML string (~15-45% faster, requires dangerouslySetInnerHTML)
91+
* - 'tokens': Returns Shiki tokens for custom rendering (hook only)
9092
* @default 'react'
9193
*/
92-
outputFormat?: 'react' | 'html';
94+
outputFormat?: OutputFormat;
9395

9496
/**
9597
* Custom Shiki highlighter instance to use instead of the default one.
@@ -164,22 +166,41 @@ interface TimeoutState {
164166
}
165167

166168
/**
167-
* Public API signature for the useShikiHighlighter hook.
169+
* Supported output formats and their corresponding result shapes.
168170
*/
169-
type HighlightedCode = ReactElement | string | null;
171+
type OutputFormat = 'react' | 'html' | 'tokens';
172+
type ComponentOutputFormat = Exclude<OutputFormat, 'tokens'>;
173+
174+
interface HighlightResultMap {
175+
react: ReactElement;
176+
html: string;
177+
tokens: TokensResult;
178+
}
179+
180+
type HighlightResult<F extends OutputFormat = 'react'> =
181+
HighlightResultMap[F] | null;
182+
183+
type HighlightedCode = HighlightResult<OutputFormat>;
184+
type ComponentHighlightedCode = HighlightResult<ComponentOutputFormat>;
185+
186+
type BaseHighlighterOptions = Omit<HighlighterOptions, 'outputFormat'>;
187+
188+
type ComponentHighlighterOptions = BaseHighlighterOptions & {
189+
outputFormat?: ComponentOutputFormat;
190+
};
170191

171192
type HighlighterFactory = (
172193
langsToLoad: Language[],
173194
themesToLoad: Theme[],
174195
engine?: Awaitable<RegexEngine>
175196
) => Promise<Highlighter | HighlighterCore>;
176197

177-
export type UseShikiHighlighter = (
198+
export type UseShikiHighlighter = <F extends OutputFormat = 'react'>(
178199
code: string,
179200
lang: Language,
180201
themeInput: Theme | Themes,
181-
options?: HighlighterOptions
182-
) => HighlightedCode;
202+
options?: BaseHighlighterOptions & { outputFormat?: F }
203+
) => HighlightResult<F>;
183204

184205
export type {
185206
Language,
@@ -188,6 +209,13 @@ export type {
188209
Element,
189210
TimeoutState,
190211
HighlighterOptions,
212+
BaseHighlighterOptions,
213+
ComponentHighlighterOptions,
214+
ComponentHighlightedCode,
191215
HighlightedCode,
216+
HighlightResult,
217+
HighlightResultMap,
218+
OutputFormat,
219+
ComponentOutputFormat,
192220
HighlighterFactory,
193221
};

0 commit comments

Comments
 (0)