Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions packages/react/src/components/code-block.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import { getHighlighter, resolveLang, THEME } from "../lib/shiki";
import {
getHighlighter,
resolveLang,
useResolvedShikiTheme,
type ShikiThemeProp,
type SupportedTheme,
} from "../lib/shiki";
import { cn } from "../lib/utils";
import { Button } from "./button";

Expand Down Expand Up @@ -55,7 +61,7 @@ const CheckIcon = () => (
// Highlight hook
// ---------------------------------------------------------------------------

function useHighlighted(code: string, lang: string): ReactNode | null {
function useHighlighted(code: string, lang: string, theme: SupportedTheme): ReactNode | null {
const [highlighted, setHighlighted] = useState<ReactNode | null>(null);

useEffect(() => {
Expand All @@ -64,7 +70,7 @@ function useHighlighted(code: string, lang: string): ReactNode | null {
getHighlighter().then((highlighter) => {
if (cancelled) return;

const hast = highlighter.codeToHast(code, { lang, theme: THEME });
const hast = highlighter.codeToHast(code, { lang, theme });
const nodes = toJsxRuntime(hast, { jsx, jsxs, Fragment });

if (!cancelled) setHighlighted(nodes);
Expand All @@ -73,7 +79,7 @@ function useHighlighted(code: string, lang: string): ReactNode | null {
return () => {
cancelled = true;
};
}, [code, lang]);
}, [code, lang, theme]);

return highlighted;
}
Expand All @@ -88,13 +94,15 @@ export function CodeBlock(props: {
title?: string;
maxHeight?: string;
className?: string;
theme?: ShikiThemeProp;
}) {
const { code, lang: langHint, title, className } = props;
const { code, lang: langHint, title, className, theme } = props;
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);

const language = useMemo(() => detectLanguage(code, langHint), [code, langHint]);
const highlighted = useHighlighted(code, language);
const resolvedTheme = useResolvedShikiTheme(theme);
const highlighted = useHighlighted(code, language, resolvedTheme);

const lines = code.split("\n");
const isLong = lines.length > 24;
Expand Down
19 changes: 13 additions & 6 deletions packages/react/src/components/expandable-code-block.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useCallback, useEffect, useMemo, useState, startTransition } from "react";
import { getHighlighter, THEME } from "../lib/shiki";
import {
getHighlighter,
useResolvedShikiTheme,
type ShikiThemeProp,
type SupportedTheme,
} from "../lib/shiki";
import { cn } from "../lib/utils";
import { Button } from "./button";
import type { ThemedToken } from "shiki/core";
Expand Down Expand Up @@ -98,7 +103,7 @@ const CheckIcon = () => (
// Shiki tokenization hook — non-blocking
// ---------------------------------------------------------------------------

function useTokens(code: string): ThemedToken[][] | null {
function useTokens(code: string, theme: SupportedTheme): ThemedToken[][] | null {
const [tokens, setTokens] = useState<ThemedToken[][] | null>(null);

useEffect(() => {
Expand All @@ -107,7 +112,7 @@ function useTokens(code: string): ThemedToken[][] | null {
if (cancelled) return;
const result = highlighter.codeToTokens(code, {
lang: "typescript",
theme: THEME,
theme,
});
if (!cancelled) {
startTransition(() => setTokens(result.tokens));
Expand All @@ -116,7 +121,7 @@ function useTokens(code: string): ThemedToken[][] | null {
return () => {
cancelled = true;
};
}, [code]);
}, [code, theme]);

return tokens;
}
Expand Down Expand Up @@ -300,8 +305,10 @@ export function ExpandableCodeBlock(props: {
code: string;
definitions?: readonly Definition[];
className?: string;
theme?: ShikiThemeProp;
}) {
const { code, definitions = [], className } = props;
const { code, definitions = [], className, theme } = props;
const resolvedTheme = useResolvedShikiTheme(theme);
// Auto-expand trivial aliases (primitives, simple unions, string literals)
const trivialNames = useMemo(() => {
const trivial = new Set<string>();
Expand Down Expand Up @@ -348,7 +355,7 @@ export function ExpandableCodeBlock(props: {
return formatTypeScript(withExpansions);
}, [code, allExpanded, definitionMap, emptyAncestors]);

const tokens = useTokens(displayCode);
const tokens = useTokens(displayCode, resolvedTheme);

const handleToggle = useCallback((name: string) => {
setExpanded((prev) => {
Expand Down
22 changes: 22 additions & 0 deletions packages/react/src/hooks/use-is-dark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";

/**
* Returns `true` when the user's system prefers a dark color scheme.
* Reactive: updates when the media query changes.
*/
export function useIsDark(): boolean {
const [dark, setDark] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});

useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (event: MediaQueryListEvent) => setDark(event.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);

return dark;
}
50 changes: 45 additions & 5 deletions packages/react/src/lib/shiki.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHighlighterCore, type HighlighterCore, type LanguageInput } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import { useIsDark } from "../hooks/use-is-dark";

// ---------------------------------------------------------------------------
// Supported languages — explicit imports to avoid bundling all grammars
Expand Down Expand Up @@ -96,7 +97,29 @@ const LANG_LOADERS: Record<SupportedLang, () => LanguageInput> = {

const supportedSet = new Set<string>([...SUPPORTED_LANGS, ...Object.keys(LANG_ALIASES)]);

export const THEME = "vitesse-dark";
export const SUPPORTED_THEMES = ["github-dark", "github-light"] as const;
export type SupportedTheme = (typeof SUPPORTED_THEMES)[number];

export const DEFAULT_LIGHT_THEME: SupportedTheme = "github-light";
export const DEFAULT_DARK_THEME: SupportedTheme = "github-dark";

export type ShikiThemeProp =
| SupportedTheme
| { light: SupportedTheme; dark: SupportedTheme };

/**
* Resolve a `ShikiThemeProp` (either a single theme or a `{ light, dark }`
* pair) to the theme that should currently be used, reacting to system
* dark-mode changes. When no theme is provided, the default github pair is
* used.
*/
export function useResolvedShikiTheme(theme?: ShikiThemeProp): SupportedTheme {
const isDark = useIsDark();
if (typeof theme === "string") return theme;
const light = theme?.light ?? DEFAULT_LIGHT_THEME;
const dark = theme?.dark ?? DEFAULT_DARK_THEME;
return isDark ? dark : light;
}

export function resolveLang(lang: string): SupportedLang | null {
const l = lang.trim().toLowerCase();
Expand All @@ -120,7 +143,10 @@ let _promise: Promise<HighlighterCore> | null = null;
export function getHighlighter(): Promise<HighlighterCore> {
if (!_promise) {
_promise = createHighlighterCore({
themes: [import("@shikijs/themes/vitesse-dark")],
themes: [
import("@shikijs/themes/github-dark"),
import("@shikijs/themes/github-light"),
],
langs: Object.values(LANG_LOADERS).map((loader) => loader()),
engine: jsEngine,
});
Expand All @@ -137,17 +163,31 @@ import type { CodeHighlighterPlugin, ThemeInput } from "streamdown";
const tokensCache = new Map<string, unknown>();
const pendingCallbacks = new Map<string, Set<(result: unknown) => void>>();

/**
* Read the current system color-scheme preference synchronously. Used in
* non-React contexts (like the streamdown plugin) where hooks aren't
* available.
*/
const prefersDarkNow = (): boolean => {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
};

export function createCodeHighlighterPlugin(): CodeHighlighterPlugin {
return {
name: "shiki" as const,
type: "code-highlighter" as const,
getSupportedLanguages: () => [...SUPPORTED_LANGS] as string[] as never,
getThemes: () => [THEME as ThemeInput, THEME as ThemeInput],
getThemes: () => [
DEFAULT_LIGHT_THEME as ThemeInput,
DEFAULT_DARK_THEME as ThemeInput,
],
supportsLanguage: (language: string) => isSupportedLang(language),
highlight(options, callback) {
const resolved = resolveLang(options.language);
const lang = resolved ?? "json";
const key = `${lang}:${options.code.length}:${options.code.slice(0, 128)}`;
const activeTheme = prefersDarkNow() ? DEFAULT_DARK_THEME : DEFAULT_LIGHT_THEME;
const key = `${activeTheme}:${lang}:${options.code.length}:${options.code.slice(0, 128)}`;

const cached = tokensCache.get(key);
if (cached) return cached as never;
Expand All @@ -168,7 +208,7 @@ export function createCodeHighlighterPlugin(): CodeHighlighterPlugin {
}
const result = highlighter.codeToTokens(options.code, {
lang,
themes: { light: THEME, dark: THEME },
themes: { light: activeTheme, dark: activeTheme },
});
tokensCache.set(key, result);
pendingCallbacks.get(key)?.forEach((cb) => cb(result));
Expand Down
Loading