Skip to content

Commit 03a7589

Browse files
committed
feat: support custom shiki themes in code blocks
Add support for custom shiki theme objects (ThemeRegistrationAny-compatible) in addition to built-in BundledTheme string names. - Add CustomTheme interface and ThemeInput type alias - Widen theme types across CodePluginOptions, CodeHighlighterPlugin, HighlightOptions, StreamdownProps, and StreamdownContextType - Extract theme names from objects for cache keys and codeToTokens - Pass theme objects to createHighlighter for registration - Add tests for custom theme objects and mixed themes - Full backward compatibility with existing BundledTheme usage Closes #409
1 parent 89b44cd commit 03a7589

File tree

4 files changed

+156
-21
lines changed

4 files changed

+156
-21
lines changed

packages/streamdown-code/__tests__/index.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { BundledLanguage } from "shiki";
22
import { describe, expect, it, vi } from "vitest";
3+
import type { CustomTheme } from "../index";
34
import { code, createCodePlugin } from "../index";
45

56
describe("code", () => {
@@ -303,4 +304,87 @@ describe("createCodePlugin", () => {
303304
expect(typeof plugin.getSupportedLanguages).toBe("function");
304305
expect(typeof plugin.getThemes).toBe("function");
305306
});
307+
308+
it("should create plugin with custom theme objects", () => {
309+
const customLight: CustomTheme = {
310+
name: "my-light-theme",
311+
type: "light",
312+
colors: { "editor.background": "#ffffff" },
313+
tokenColors: [],
314+
};
315+
const customDark: CustomTheme = {
316+
name: "my-dark-theme",
317+
type: "dark",
318+
colors: { "editor.background": "#1e1e1e" },
319+
tokenColors: [],
320+
};
321+
const plugin = createCodePlugin({
322+
themes: [customLight, customDark],
323+
});
324+
const themes = plugin.getThemes();
325+
expect(themes[0]).toBe(customLight);
326+
expect(themes[1]).toBe(customDark);
327+
});
328+
329+
it("should create plugin with mixed built-in and custom themes", () => {
330+
const customDark: CustomTheme = {
331+
name: "my-dark-theme",
332+
type: "dark",
333+
colors: { "editor.background": "#1e1e1e" },
334+
tokenColors: [],
335+
};
336+
const plugin = createCodePlugin({
337+
themes: ["github-light", customDark],
338+
});
339+
const themes = plugin.getThemes();
340+
expect(themes[0]).toBe("github-light");
341+
expect(themes[1]).toBe(customDark);
342+
});
343+
344+
it("should highlight code with custom theme objects", async () => {
345+
const customLight: CustomTheme = {
346+
name: "custom-light",
347+
type: "light",
348+
colors: {
349+
"editor.background": "#ffffff",
350+
"editor.foreground": "#000000",
351+
},
352+
tokenColors: [],
353+
};
354+
const customDark: CustomTheme = {
355+
name: "custom-dark",
356+
type: "dark",
357+
colors: {
358+
"editor.background": "#1e1e1e",
359+
"editor.foreground": "#d4d4d4",
360+
},
361+
tokenColors: [],
362+
};
363+
const plugin = createCodePlugin({
364+
themes: [customLight, customDark],
365+
});
366+
367+
const callback = vi.fn();
368+
const result = plugin.highlight(
369+
{
370+
code: "const x = 1;",
371+
language: "javascript",
372+
themes: [customLight, customDark],
373+
},
374+
callback
375+
);
376+
377+
expect(result).toBeNull();
378+
379+
await vi.waitFor(
380+
() => {
381+
expect(callback).toHaveBeenCalled();
382+
},
383+
{ timeout: 5000 }
384+
);
385+
386+
const highlightResult = callback.mock.calls[0][0];
387+
expect(highlightResult.tokens).toBeDefined();
388+
expect(Array.isArray(highlightResult.tokens)).toBe(true);
389+
});
306390
});

packages/streamdown-code/index.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
1414

1515
const jsEngine = createJavaScriptRegexEngine({ forgiving: true });
1616

17+
/**
18+
* A custom theme object compatible with shiki's ThemeRegistrationAny.
19+
* Must have a `name` property for identification and caching.
20+
*/
21+
export interface CustomTheme {
22+
name: string;
23+
[key: string]: unknown;
24+
}
25+
26+
/**
27+
* Theme input type that accepts either a built-in theme name or a custom theme object.
28+
*/
29+
export type ThemeInput = BundledTheme | CustomTheme;
30+
1731
/**
1832
* Result from code highlighting
1933
*/
@@ -25,7 +39,7 @@ export type HighlightResult = TokensResult;
2539
export interface HighlightOptions {
2640
code: string;
2741
language: BundledLanguage;
28-
themes: [string, string];
42+
themes: [string, string] | [ThemeInput, ThemeInput];
2943
}
3044

3145
/**
@@ -54,6 +68,14 @@ export interface CodeHighlighterPlugin {
5468
* Check if language is supported
5569
*/
5670
supportsLanguage: (language: BundledLanguage) => boolean;
71+
/**
72+
* Get list of supported languages
73+
*/
74+
getSupportedLanguages: () => BundledLanguage[];
75+
/**
76+
* Get the configured themes
77+
*/
78+
getThemes: () => [ThemeInput, ThemeInput];
5779
type: "code-highlighter";
5880
}
5981

@@ -63,9 +85,10 @@ export interface CodeHighlighterPlugin {
6385
export interface CodePluginOptions {
6486
/**
6587
* Default themes for syntax highlighting [light, dark]
88+
* Accepts built-in theme names or custom theme objects.
6689
* @default ["github-light", "github-dark"]
6790
*/
68-
themes?: [BundledTheme, BundledTheme];
91+
themes?: [ThemeInput, ThemeInput];
6992
}
7093

7194
const languageAliases = Object.fromEntries(
@@ -104,10 +127,13 @@ const tokensCache = new Map<string, TokensResult>();
104127
// Subscribers for async token updates
105128
const subscribers = new Map<string, Set<(result: TokensResult) => void>>();
106129

130+
const getThemeName = (theme: ThemeInput): string =>
131+
typeof theme === "string" ? theme : theme.name;
132+
107133
const getHighlighterCacheKey = (
108134
language: BundledLanguage,
109-
themeNames: [string, string]
110-
) => `${language}-${themeNames[0]}-${themeNames[1]}`;
135+
themes: [ThemeInput, ThemeInput]
136+
) => `${language}-${getThemeName(themes[0])}-${getThemeName(themes[1])}`;
111137

112138
const getTokensCacheKey = (
113139
code: string,
@@ -121,9 +147,9 @@ const getTokensCacheKey = (
121147

122148
const getHighlighter = (
123149
language: BundledLanguage,
124-
themeNames: [string, string]
150+
themes: [ThemeInput, ThemeInput]
125151
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
126-
const cacheKey = getHighlighterCacheKey(language, themeNames);
152+
const cacheKey = getHighlighterCacheKey(language, themes);
127153

128154
if (highlighterCache.has(cacheKey)) {
129155
return highlighterCache.get(cacheKey) as Promise<
@@ -132,7 +158,7 @@ const getHighlighter = (
132158
}
133159

134160
const highlighterPromise = createHighlighter({
135-
themes: themeNames,
161+
themes: themes as (string | Record<string, unknown>)[],
136162
langs: [language],
137163
engine: jsEngine,
138164
});
@@ -147,7 +173,7 @@ const getHighlighter = (
147173
export function createCodePlugin(
148174
options: CodePluginOptions = {}
149175
): CodeHighlighterPlugin {
150-
const defaultThemes: [BundledTheme, BundledTheme] = options.themes ?? [
176+
const defaultThemes: [ThemeInput, ThemeInput] = options.themes ?? [
151177
"github-light",
152178
"github-dark",
153179
];
@@ -165,19 +191,23 @@ export function createCodePlugin(
165191
return Array.from(languageNames);
166192
},
167193

168-
getThemes(): [BundledTheme, BundledTheme] {
194+
getThemes(): [ThemeInput, ThemeInput] {
169195
return defaultThemes;
170196
},
171197

172198
highlight(
173-
{ code, language, themes: themeNames }: HighlightOptions,
199+
{ code, language, themes }: HighlightOptions,
174200
callback?: (result: HighlightResult) => void
175201
): HighlightResult | null {
176202
const resolvedLanguage = normalizeLanguage(language);
203+
const themeNames: [string, string] = [
204+
getThemeName(themes[0]),
205+
getThemeName(themes[1]),
206+
];
177207
const tokensCacheKey = getTokensCacheKey(
178208
code,
179209
resolvedLanguage,
180-
themeNames as [string, string]
210+
themeNames
181211
);
182212

183213
// Return cached result if available
@@ -197,10 +227,7 @@ export function createCodePlugin(
197227
}
198228

199229
// Start highlighting in background
200-
getHighlighter(
201-
resolvedLanguage as BundledLanguage,
202-
themeNames as [string, string]
203-
)
230+
getHighlighter(resolvedLanguage as BundledLanguage, defaultThemes)
204231
.then((highlighter) => {
205232
const availableLangs = highlighter.getLoadedLanguages();
206233
const langToUse = (

packages/streamdown/index.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import rehypeRaw from "rehype-raw";
1616
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
1717
import remarkGfm from "remark-gfm";
1818
import remend, { type RemendOptions } from "remend";
19-
import type { BundledTheme } from "shiki";
2019
import type { Pluggable } from "unified";
2120
import { type AnimateOptions, createAnimatePlugin } from "./lib/animate";
2221
import { BlockIncompleteContext } from "./lib/block-incomplete-context";
@@ -25,7 +24,7 @@ import { hasIncompleteCodeFence, hasTable } from "./lib/incomplete-code-utils";
2524
import { Markdown, type Options } from "./lib/markdown";
2625
import { parseMarkdownIntoBlocks } from "./lib/parse-blocks";
2726
import { PluginContext } from "./lib/plugin-context";
28-
import type { PluginConfig } from "./lib/plugin-types";
27+
import type { PluginConfig, ThemeInput } from "./lib/plugin-types";
2928
import { preprocessCustomTags } from "./lib/preprocess-custom-tags";
3029
import { cn } from "./lib/utils";
3130

@@ -45,10 +44,12 @@ export { parseMarkdownIntoBlocks } from "./lib/parse-blocks";
4544
export type {
4645
CjkPlugin,
4746
CodeHighlighterPlugin,
47+
CustomTheme,
4848
DiagramPlugin,
4949
HighlightOptions,
5050
MathPlugin,
5151
PluginConfig,
52+
ThemeInput,
5253
} from "./lib/plugin-types";
5354

5455
// Patterns for HTML indentation normalization
@@ -128,7 +129,7 @@ export type StreamdownProps = Options & {
128129
/** Normalize HTML block indentation to prevent 4+ spaces being treated as code blocks. @default false */
129130
normalizeHtmlIndentation?: boolean;
130131
className?: string;
131-
shikiTheme?: [BundledTheme, BundledTheme];
132+
shikiTheme?: [ThemeInput, ThemeInput];
132133
mermaid?: MermaidOptions;
133134
controls?: ControlsConfig;
134135
isAnimating?: boolean;
@@ -179,12 +180,12 @@ const carets = {
179180

180181
// Combined context for better performance - reduces React tree depth from 5 nested providers to 1
181182
export interface StreamdownContextType {
183+
shikiTheme: [ThemeInput, ThemeInput];
182184
controls: ControlsConfig;
183185
isAnimating: boolean;
184186
linkSafety?: LinkSafetyConfig;
185187
mermaid?: MermaidOptions;
186188
mode: "static" | "streaming";
187-
shikiTheme: [BundledTheme, BundledTheme];
188189
}
189190

190191
const defaultStreamdownContext: StreamdownContextType = {
@@ -285,7 +286,7 @@ export const Block = memo(
285286

286287
Block.displayName = "Block";
287288

288-
const defaultShikiTheme: [BundledTheme, BundledTheme] = [
289+
const defaultShikiTheme: [ThemeInput, ThemeInput] = [
289290
"github-light",
290291
"github-dark",
291292
];

packages/streamdown/lib/plugin-types.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import type { MermaidConfig } from "mermaid";
22
import type { BundledLanguage, BundledTheme } from "shiki";
3+
4+
/**
5+
* A custom theme object compatible with shiki's ThemeRegistrationAny.
6+
* Must have a `name` property for identification and caching.
7+
*/
8+
export interface CustomTheme {
9+
name: string;
10+
[key: string]: unknown;
11+
}
12+
13+
/**
14+
* Theme input type that accepts either a built-in theme name or a custom theme object.
15+
*/
16+
export type ThemeInput = BundledTheme | CustomTheme;
17+
318
import type { Pluggable } from "unified";
419

520
/**
@@ -30,7 +45,7 @@ export interface HighlightResult {
3045
export interface HighlightOptions {
3146
code: string;
3247
language: BundledLanguage;
33-
themes: [string, string];
48+
themes: [string, string] | [ThemeInput, ThemeInput];
3449
}
3550

3651
/**
@@ -59,6 +74,14 @@ export interface CodeHighlighterPlugin {
5974
* Check if language is supported
6075
*/
6176
supportsLanguage: (language: BundledLanguage) => boolean;
77+
/**
78+
* Get list of supported languages
79+
*/
80+
getSupportedLanguages: () => BundledLanguage[];
81+
/**
82+
* Get the configured themes
83+
*/
84+
getThemes: () => [ThemeInput, ThemeInput];
6285
type: "code-highlighter";
6386
}
6487

0 commit comments

Comments
 (0)