mirror of
https://github.com/vercel/streamdown.git
synced 2026-09-19 02:18:37 +08:00
651873df54
* fix(streamdown-code): fall back to plain text for unknown/truncated language identifiers When a code block's language identifier is unrecognised by Shiki (e.g. 'rus' from a network-truncated 'rust'), createHighlighter throws a ShikiError. Previously the .catch() block only logged the error and left subscribers hanging, causing a permanent React render-cycle stall. Fix: resolve the language to the 'text' fallback *before* calling getHighlighter() when the identifier is not in the bundledLanguages set. This prevents the exception entirely and ensures the code block renders with plain-text highlighting instead of breaking. Fixes #433 * Add changeset for unknown language fallback fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add test for unknown/truncated language fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix TypeScript error: widen safeLanguage type to include SpecialLanguage `'text'` is a SpecialLanguage in Shiki, not a BundledLanguage, so the type annotations for getHighlighter and getHighlighterCacheKey need to accept both. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix formatting: remove trailing comma, use double quotes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Troitskii <jsleitor@gmail.com> Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
6.9 KiB
TypeScript
260 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
type BundledLanguage,
|
|
type BundledTheme,
|
|
bundledLanguages,
|
|
bundledLanguagesInfo,
|
|
createHighlighter,
|
|
type HighlighterGeneric,
|
|
type SpecialLanguage,
|
|
type ThemeRegistrationAny,
|
|
type TokensResult,
|
|
} from "shiki";
|
|
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
|
|
|
const jsEngine = createJavaScriptRegexEngine({ forgiving: true });
|
|
|
|
export type ThemeInput = BundledTheme | ThemeRegistrationAny;
|
|
|
|
/**
|
|
* Result from code highlighting
|
|
*/
|
|
export type HighlightResult = TokensResult;
|
|
|
|
/**
|
|
* Options for highlighting code
|
|
*/
|
|
export interface HighlightOptions {
|
|
code: string;
|
|
language: BundledLanguage;
|
|
themes: [ThemeInput, ThemeInput];
|
|
}
|
|
|
|
/**
|
|
* Plugin for code syntax highlighting (Shiki)
|
|
*/
|
|
export interface CodeHighlighterPlugin {
|
|
/**
|
|
* Get list of supported languages
|
|
*/
|
|
getSupportedLanguages: () => BundledLanguage[];
|
|
/**
|
|
* Get the configured themes
|
|
*/
|
|
getThemes: () => [ThemeInput, ThemeInput];
|
|
/**
|
|
* Highlight code and return tokens
|
|
* Returns null if highlighting not ready yet (async loading)
|
|
* Use callback for async result
|
|
*/
|
|
highlight: (
|
|
options: HighlightOptions,
|
|
callback?: (result: HighlightResult) => void
|
|
) => HighlightResult | null;
|
|
name: "shiki";
|
|
/**
|
|
* Check if language is supported
|
|
*/
|
|
supportsLanguage: (language: BundledLanguage) => boolean;
|
|
type: "code-highlighter";
|
|
}
|
|
|
|
/**
|
|
* Options for creating a code plugin
|
|
*/
|
|
export interface CodePluginOptions {
|
|
/**
|
|
* Default themes for syntax highlighting [light, dark]
|
|
* @default ["github-light", "github-dark"]
|
|
*/
|
|
themes?: [ThemeInput, ThemeInput];
|
|
}
|
|
|
|
const languageAliases = Object.fromEntries(
|
|
bundledLanguagesInfo.flatMap((info) =>
|
|
(info.aliases ?? []).map((alias) => [alias, info.id as BundledLanguage])
|
|
)
|
|
) as Record<string, BundledLanguage>;
|
|
|
|
// Build language name set for quick lookup
|
|
const languageNames = new Set<BundledLanguage>(
|
|
Object.keys(bundledLanguages) as BundledLanguage[]
|
|
);
|
|
|
|
const normalizeLanguage = (language: string): string => {
|
|
const trimmed = language.trim();
|
|
const lower = trimmed.toLowerCase();
|
|
const alias = languageAliases[lower];
|
|
if (alias) {
|
|
return alias;
|
|
}
|
|
if (languageNames.has(lower as BundledLanguage)) {
|
|
return lower;
|
|
}
|
|
return lower;
|
|
};
|
|
|
|
// Singleton highlighter cache
|
|
const highlighterCache = new Map<
|
|
string,
|
|
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
|
>();
|
|
|
|
// Token cache
|
|
const tokensCache = new Map<string, TokensResult>();
|
|
|
|
// Subscribers for async token updates
|
|
const subscribers = new Map<string, Set<(result: TokensResult) => void>>();
|
|
|
|
const getThemeName = (theme: ThemeInput): string =>
|
|
typeof theme === "string" ? theme : (theme.name ?? "custom");
|
|
|
|
const getHighlighterCacheKey = (
|
|
language: BundledLanguage | SpecialLanguage,
|
|
themes: [ThemeInput, ThemeInput]
|
|
) => `${language}-${getThemeName(themes[0])}-${getThemeName(themes[1])}`;
|
|
|
|
const getTokensCacheKey = (
|
|
code: string,
|
|
language: string,
|
|
themeNames: [string, string]
|
|
) => {
|
|
const start = code.slice(0, 100);
|
|
const end = code.length > 100 ? code.slice(-100) : "";
|
|
return `${language}:${themeNames[0]}:${themeNames[1]}:${code.length}:${start}:${end}`;
|
|
};
|
|
|
|
const getHighlighter = (
|
|
language: BundledLanguage | SpecialLanguage,
|
|
themes: [ThemeInput, ThemeInput]
|
|
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
|
const cacheKey = getHighlighterCacheKey(language, themes);
|
|
|
|
if (highlighterCache.has(cacheKey)) {
|
|
return highlighterCache.get(cacheKey) as Promise<
|
|
HighlighterGeneric<BundledLanguage, BundledTheme>
|
|
>;
|
|
}
|
|
|
|
const highlighterPromise = createHighlighter({
|
|
themes,
|
|
langs: [language],
|
|
engine: jsEngine,
|
|
});
|
|
|
|
highlighterCache.set(cacheKey, highlighterPromise);
|
|
return highlighterPromise;
|
|
};
|
|
|
|
/**
|
|
* Create a code plugin with optional configuration
|
|
*/
|
|
export function createCodePlugin(
|
|
options: CodePluginOptions = {}
|
|
): CodeHighlighterPlugin {
|
|
const defaultThemes: [ThemeInput, ThemeInput] = options.themes ?? [
|
|
"github-light",
|
|
"github-dark",
|
|
];
|
|
|
|
return {
|
|
name: "shiki",
|
|
type: "code-highlighter",
|
|
|
|
supportsLanguage(language: BundledLanguage): boolean {
|
|
const resolvedLanguage = normalizeLanguage(language);
|
|
return languageNames.has(resolvedLanguage as BundledLanguage);
|
|
},
|
|
|
|
getSupportedLanguages(): BundledLanguage[] {
|
|
return Array.from(languageNames);
|
|
},
|
|
|
|
getThemes(): [ThemeInput, ThemeInput] {
|
|
return defaultThemes;
|
|
},
|
|
|
|
highlight(
|
|
{ code, language, themes }: HighlightOptions,
|
|
callback?: (result: HighlightResult) => void
|
|
): HighlightResult | null {
|
|
const resolvedLanguage = normalizeLanguage(language);
|
|
const themeNames: [string, string] = [
|
|
getThemeName(themes[0]),
|
|
getThemeName(themes[1]),
|
|
];
|
|
const tokensCacheKey = getTokensCacheKey(
|
|
code,
|
|
resolvedLanguage,
|
|
themeNames
|
|
);
|
|
|
|
// Return cached result if available
|
|
if (tokensCache.has(tokensCacheKey)) {
|
|
return tokensCache.get(tokensCacheKey) as TokensResult;
|
|
}
|
|
|
|
// Subscribe callback if provided
|
|
if (callback) {
|
|
if (!subscribers.has(tokensCacheKey)) {
|
|
subscribers.set(tokensCacheKey, new Set());
|
|
}
|
|
const subs = subscribers.get(tokensCacheKey) as Set<
|
|
(result: TokensResult) => void
|
|
>;
|
|
subs.add(callback);
|
|
}
|
|
|
|
// Resolve language to 'text' if not supported (e.g. truncated identifier)
|
|
const safeLanguage: BundledLanguage | SpecialLanguage = languageNames.has(
|
|
resolvedLanguage as BundledLanguage
|
|
)
|
|
? (resolvedLanguage as BundledLanguage)
|
|
: "text";
|
|
|
|
// Start highlighting in background
|
|
getHighlighter(safeLanguage, themes)
|
|
.then((highlighter) => {
|
|
const availableLangs = highlighter.getLoadedLanguages();
|
|
const langToUse = (
|
|
availableLangs.includes(resolvedLanguage as BundledLanguage)
|
|
? (resolvedLanguage as BundledLanguage)
|
|
: "text"
|
|
) as BundledLanguage | SpecialLanguage;
|
|
|
|
const result = highlighter.codeToTokens(code, {
|
|
lang: langToUse,
|
|
themes: {
|
|
light: themeNames[0],
|
|
dark: themeNames[1],
|
|
},
|
|
});
|
|
|
|
// Cache the result
|
|
tokensCache.set(tokensCacheKey, result);
|
|
|
|
// Notify all subscribers
|
|
const subs = subscribers.get(tokensCacheKey);
|
|
if (subs) {
|
|
for (const sub of subs) {
|
|
sub(result);
|
|
}
|
|
subscribers.delete(tokensCacheKey);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("[Streamdown Code] Failed to highlight code:", error);
|
|
subscribers.delete(tokensCacheKey);
|
|
});
|
|
|
|
return null;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pre-configured code plugin with default settings
|
|
*/
|
|
export const code = createCodePlugin();
|