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
12 changes: 6 additions & 6 deletions packages/react/src/components/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import {
dualThemeOptions,
getHighlighter,
ensureLang,
resolveLang,
useResolvedShikiTheme,
type ShikiThemeProp,
type SupportedTheme,
} from "../lib/shiki";
import { cn } from "../lib/utils";
import { Button } from "./button";
Expand Down Expand Up @@ -62,7 +61,7 @@ const CheckIcon = () => (
// Highlight hook
// ---------------------------------------------------------------------------

function useHighlighted(code: string, lang: string, theme: SupportedTheme): ReactNode | null {
function useHighlighted(code: string, lang: string, theme?: ShikiThemeProp): ReactNode | null {
const [, setTick] = useState(0);
const resolvedLang = (resolveLang(lang) ?? "json") as Parameters<typeof ensureLang>[0];

Expand All @@ -71,7 +70,9 @@ function useHighlighted(code: string, lang: string, theme: SupportedTheme): Reac
if (!isReady) return null;

const highlighter = getHighlighter();
const hast = highlighter.codeToHast(code, { lang: resolvedLang, theme });
// Dual-theme light-dark() colors: the markup is correct in BOTH color
// schemes, so SSR/hydration can't paint the wrong palette first.
const hast = highlighter.codeToHast(code, { lang: resolvedLang, ...dualThemeOptions(theme) });
return toJsxRuntime(hast, { jsx, jsxs, Fragment });
}

Expand All @@ -92,8 +93,7 @@ export function CodeBlock(props: {
const [copied, setCopied] = useState(false);

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

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

function useTokens(code: string, theme: SupportedTheme): ThemedToken[][] {
function useTokens(code: string, theme?: ShikiThemeProp): ThemedToken[][] {
const highlighter = getHighlighter();
// Dual-theme light-dark() colors (token.htmlStyle): correct in both color
// schemes from the first frame — no JS dark-mode probe to catch up to.
const result = highlighter.codeToTokens(code, {
lang: "typescript",
theme,
...dualThemeOptions(theme),
});
return result.tokens;
}

/** A dual-theme token's inline style (light-dark color + per-theme CSS vars). */
const tokenStyle = (token: Pick<ThemedToken, "color" | "htmlStyle">): CSSProperties | undefined =>
(token.htmlStyle as CSSProperties | undefined) ??
(token.color ? { color: token.color } : undefined);

// ---------------------------------------------------------------------------
// Inline-expand logic
//
Expand Down Expand Up @@ -176,12 +178,13 @@ const applyExpansions = (
// ---------------------------------------------------------------------------

type RenderToken =
| { kind: "text"; content: string; color?: string }
| { kind: "ref"; name: string; color?: string };
| { kind: "text"; content: string; style?: CSSProperties }
| { kind: "ref"; name: string; style?: CSSProperties };

const splitToken = (token: ThemedToken, clickableNames: ReadonlySet<string>): RenderToken[] => {
const style = tokenStyle(token);
if (clickableNames.size === 0) {
return [{ kind: "text", content: token.content, color: token.color }];
return [{ kind: "text", content: token.content, style }];
}

const text = token.content;
Expand All @@ -205,14 +208,14 @@ const splitToken = (token: ThemedToken, clickableNames: ReadonlySet<string>): Re
}

if (earliest === -1) {
results.push({ kind: "text", content: remaining, color: token.color });
results.push({ kind: "text", content: remaining, style });
break;
}

if (earliest > 0) {
results.push({ kind: "text", content: remaining.slice(0, earliest), color: token.color });
results.push({ kind: "text", content: remaining.slice(0, earliest), style });
}
results.push({ kind: "ref", name: matchedName, color: token.color });
results.push({ kind: "ref", name: matchedName, style });
remaining = remaining.slice(earliest + matchedName.length);
}

Expand Down Expand Up @@ -242,7 +245,7 @@ function HighlightedCode(props: {
return parts.map((part, pi) => {
if (part.kind === "text") {
return (
<span key={`${ti}-${pi}`} style={part.color ? { color: part.color } : undefined}>
<span key={`${ti}-${pi}`} style={part.style}>
{part.content}
</span>
);
Expand All @@ -269,7 +272,7 @@ function HighlightedCode(props: {
"cursor-pointer underline underline-offset-2 hover:opacity-80",
isExpanded ? "decoration-current/50" : "decoration-current/30",
)}
style={part.color ? { color: part.color } : undefined}
style={part.style}
title={isExpanded ? `Collapse ${part.name}` : `Expand ${part.name}`}
>
{part.name}
Expand All @@ -294,7 +297,6 @@ export function ExpandableCodeBlock(props: {
theme?: ShikiThemeProp;
}) {
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 @@ -341,7 +343,7 @@ export function ExpandableCodeBlock(props: {
return formatTypeScript(withExpansions);
}, [code, allExpanded, definitionMap, emptyAncestors]);

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

const handleToggle = useCallback((name: string) => {
setExpanded((prev) => {
Expand Down
59 changes: 28 additions & 31 deletions packages/react/src/lib/shiki.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createHighlighterCoreSync, type HighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import { useIsDark } from "../hooks/use-is-dark";

// ---------------------------------------------------------------------------
// Eagerly loaded languages (sync — available immediately)
Expand Down Expand Up @@ -113,19 +112,31 @@ 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.
* Resolve a `ShikiThemeProp` to the `{ light, dark }` pair handed to shiki's
* dual-theme mode. A single theme means "this theme in BOTH modes".
*
* Dual-theme + `light-dark()` colors is what keeps highlighting correct from
* the FIRST frame: the rendered markup carries both palettes and the
* browser's own color-scheme picks one — no JS dark-mode probe, so an SSR'd
* page can't paint light-theme tokens and then snap once `useIsDark` syncs.
*/
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 resolveShikiThemes(theme?: ShikiThemeProp): {
light: SupportedTheme;
dark: SupportedTheme;
} {
if (typeof theme === "string") return { light: theme, dark: theme };
return {
light: theme?.light ?? DEFAULT_LIGHT_THEME,
dark: theme?.dark ?? DEFAULT_DARK_THEME,
};
}

/** The shiki options that render dual-theme `light-dark(...)` colors. */
export const dualThemeOptions = (theme?: ShikiThemeProp) => ({
themes: resolveShikiThemes(theme),
defaultColor: "light-dark()" as const,
});

export function resolveLang(lang: string): SupportedLang | null {
const l = lang.trim().toLowerCase();
if (supportedSet.has(l)) {
Expand Down Expand Up @@ -197,16 +208,6 @@ import type { CodeHighlighterPlugin, ThemeInput } from "streamdown";
type HighlightResult = NonNullable<ReturnType<CodeHighlighterPlugin["highlight"]>>;
const tokensCache = new Map<string, HighlightResult>();

/**
* 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,
Expand All @@ -217,28 +218,24 @@ export function createCodeHighlighterPlugin(): CodeHighlighterPlugin {
highlight(options, callback) {
const resolved = resolveLang(options.language);
const lang = resolved ?? "json";
const activeTheme = prefersDarkNow() ? DEFAULT_DARK_THEME : DEFAULT_LIGHT_THEME;
const key = `${activeTheme}:${lang}:${options.code.length}:${options.code.slice(0, 128)}`;
// Dual-theme tokens (light-dark() colors): correct in both color
// schemes, so the cache never holds the wrong palette and a mid-stream
// scheme flip needs no re-render.
const key = `${lang}:${options.code.length}:${options.code.slice(0, 128)}`;

const cached = tokensCache.get(key);
if (cached) return cached;

const isReady = ensureLang(lang, () => {
// Language just loaded — highlight and notify via callback
const result = highlighter.codeToTokens(options.code, {
lang,
themes: { light: activeTheme, dark: activeTheme },
});
const result = highlighter.codeToTokens(options.code, { lang, ...dualThemeOptions() });
tokensCache.set(key, result);
callback?.(result);
});

if (!isReady) return null;

const result = highlighter.codeToTokens(options.code, {
lang,
themes: { light: activeTheme, dark: activeTheme },
});
const result = highlighter.codeToTokens(options.code, { lang, ...dualThemeOptions() });
tokensCache.set(key, result);
return result;
},
Expand Down
Loading