react-shiki
Version:
Syntax highlighter component for react using shiki
344 lines (343 loc) • 12.5 kB
JavaScript
import './style.css';
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import { dequal } from "dequal";
import { guessEmbeddedLanguages, isSpecialLang } from "shiki/core";
import { visit } from "unist-util-visit";
import { clsx } from "clsx";
//#region src/lib/utils.ts
/**
* Returns a referentially stable version of `value` that only updates when content changes.
* Uses reference equality as a fast path and deep comparison to prevent unnecessary updates.
*/
const useStableValue = (value) => {
const ref = useRef(value);
if (value !== ref.current && !dequal(value, ref.current)) ref.current = value;
return ref.current;
};
/**
* Optionally throttles rapid sequential highlighting operations
*
* @example
* const timeoutControl = useRef<TimeoutState>({
* nextAllowedTime: 0,
* timeoutId: undefined
* });
*
* throttleHighlighting(highlightCode, timeoutControl, 1000);
*/
const throttleHighlighting = (performHighlight, timeoutControl, throttleMs) => {
clearTimeout(timeoutControl.current.timeoutId);
const delay = Math.max(0, timeoutControl.current.nextAllowedTime - Date.now());
timeoutControl.current.timeoutId = setTimeout(() => {
performHighlight();
timeoutControl.current.nextAllowedTime = Date.now() + throttleMs;
}, delay);
};
//#endregion
//#region src/lib/language.ts
const FALLBACK_LANGUAGE = "plaintext";
const getEmbeddedLanguages = (code, languageId, highlighter) => {
const bundled = highlighter.getBundledLanguages();
return guessEmbeddedLanguages(code, languageId).flatMap((language) => bundled[language] ?? []);
};
const toArray = (value) => value == null ? [] : Array.isArray(value) ? value : [value];
const languageKey = (lang) => {
if (lang == null) return null;
if (typeof lang === "string") return `s:${lang}`;
return `o:${lang.name}::${lang.scopeName}`;
};
const dedupeLanguages = (langs) => {
const seen = /* @__PURE__ */ new Set();
const deduped = [];
for (const lang of langs) {
const key = languageKey(lang);
if (key == null || seen.has(key)) continue;
seen.add(key);
deduped.push(lang);
}
return deduped;
};
/**
* Used in factories to check if language is supported.
* Objects are validated as grammar registrations (name + scopeName).
*/
const isLoadableLanguage = (lang, bundledLanguages) => {
if (lang == null) return false;
if (typeof lang === "string") return lang in bundledLanguages;
return typeof lang.name === "string" && typeof lang.scopeName === "string";
};
/**
* Used in hook to resolve loaded language for highlighting.
* Falls back to "plaintext" if not supported.
*/
const resolveLoadedLanguage = (languageId, loadedLanguages) => isSpecialLang(languageId) || loadedLanguages.includes(languageId) ? languageId : FALLBACK_LANGUAGE;
/**
* Resolves the language input to standardized IDs and objects for Shiki
* @param lang The language input from props
* @param customLanguages An array of custom textmate grammar objects or a single grammar object
* @returns A LanguageResult object containing:
* - languageId: The resolved language ID
* - langsToLoad: The language objects/string ids to load
*/
const resolveLanguage = (lang, customLanguages, langAliases, preloadLanguages) => {
const customLangs = toArray(customLanguages);
const preloadLangs = toArray(preloadLanguages);
const customMatchPool = [...customLangs, ...preloadLangs];
let languageId = FALLBACK_LANGUAGE;
let primaryLang;
if (lang == null || typeof lang === "string" && !lang.trim()) return {
languageId,
langsToLoad: dedupeLanguages([...preloadLangs, ...customLangs])
};
if (typeof lang === "object") {
languageId = lang.name;
primaryLang = lang;
} else {
const lowerLang = lang.toLowerCase();
const matches = (str) => str?.toLowerCase() === lowerLang;
const customMatch = customMatchPool.find((candidate) => typeof candidate === "object" && candidate != null && !!(matches(candidate.name) || matches(candidate.scopeName) || matches(candidate.scopeName?.split(".").pop()) || candidate.aliases?.some(matches) || candidate.fileTypes?.some(matches)));
if (customMatch) {
languageId = customMatch.name || lang;
primaryLang = customMatch;
} else if (langAliases?.[lang]) {
languageId = langAliases[lang];
primaryLang = langAliases[lang];
} else {
languageId = lang;
primaryLang = lang;
}
}
return {
languageId,
langsToLoad: dedupeLanguages([
primaryLang,
...preloadLangs,
...customLangs
])
};
};
//#endregion
//#region src/lib/theme.ts
const DEFAULT_THEMES = {
light: "github-light",
dark: "github-dark"
};
const isTextmateTheme = (value) => {
if (typeof value !== "object" || value === null) return false;
const v = value;
const hasTokens = Array.isArray(v.tokenColors) || Array.isArray(v.settings);
const hasIdentity = typeof v.name === "string" || typeof v.type === "string";
return hasTokens && hasIdentity;
};
function resolveTheme(themeInput) {
if (typeof themeInput !== "object" || themeInput === null || isTextmateTheme(themeInput)) return {
isMulti: false,
theme: themeInput,
themesToLoad: [themeInput]
};
const validEntries = Object.entries(themeInput).filter(([key, value]) => key.trim() !== "" && (typeof value === "string" && value.trim() !== "" || isTextmateTheme(value)));
if (validEntries.length === 0) {
console.warn("[react-shiki] invalid multi-theme config, falling back to defaults");
return {
isMulti: true,
themes: DEFAULT_THEMES,
themesToLoad: Object.values(DEFAULT_THEMES)
};
}
if (validEntries.length !== Object.keys(themeInput).length) console.warn("[react-shiki] multi-theme config contained invalid entries; they were dropped");
return {
isMulti: true,
themes: Object.fromEntries(validEntries),
themesToLoad: validEntries.map(([, value]) => value)
};
}
//#endregion
//#region src/lib/transformers.ts
/**
* Creates a transformer that enables line numbers display
* @param startLine - The starting line number (defaults to 1)
*/
function lineNumbersTransformer(startLine = 1) {
return {
name: "react-shiki:line-numbers",
code(node) {
this.addClassToHast(node, "rs-has-line-numbers");
if (startLine !== 1) {
const existingStyle = node.properties?.style || "";
const newStyle = existingStyle ? `${existingStyle}; --line-start: ${startLine}` : `--line-start: ${startLine}`;
node.properties = {
...node.properties,
style: newStyle
};
}
},
line(node) {
this.addClassToHast(node, "rs-line-number");
return node;
}
};
}
//#endregion
//#region src/lib/options.ts
const buildThemeOptions = (resolvedTheme, defaultColor, cssVariablePrefix) => {
if (resolvedTheme.isMulti) return {
themes: resolvedTheme.themes,
defaultColor,
cssVariablePrefix
};
return { theme: resolvedTheme.theme };
};
const buildShikiOptions = (languageId, resolvedTheme, options) => {
const { delay, customLanguages, preloadLanguages, outputFormat, highlighter, langAlias, engine, defaultColor, cssVariablePrefix, showLineNumbers, startingLineNumber, transformers: userTransformers, ...shikiPassthrough } = options;
const transformers = [...userTransformers || []];
if (showLineNumbers) transformers.push(lineNumbersTransformer(startingLineNumber));
return {
lang: languageId,
...buildThemeOptions(resolvedTheme, defaultColor, cssVariablePrefix),
...shikiPassthrough,
transformers
};
};
//#endregion
//#region src/lib/hook.ts
async function highlight(code, resolved, opts, factory) {
const { languageId, langsToLoad, themesToLoad, shikiOptions } = resolved;
const highlighter = opts.highlighter ?? await (async () => {
const hl = await factory(langsToLoad, themesToLoad, opts.engine);
await hl.loadLanguage(...getEmbeddedLanguages(code, languageId, hl));
return hl;
})();
const language = resolveLoadedLanguage(languageId, highlighter.getLoadedLanguages());
const options = {
...shikiOptions,
lang: language
};
return opts.outputFormat === "html" ? highlighter.codeToHtml(code, options) : toJsxRuntime(highlighter.codeToHast(code, options), {
jsx,
jsxs,
Fragment
});
}
const useHighlight = (code, lang, themeInput, options = {}, highlighterFactory) => {
const [highlightedCode, setHighlightedCode] = useState(null);
const stableLang = useStableValue(lang);
const stableTheme = useStableValue(themeInput);
const stableOpts = useStableValue(options);
const resolved = useMemo(() => {
const { languageId, langsToLoad } = resolveLanguage(stableLang, stableOpts.customLanguages, stableOpts.langAlias, stableOpts.preloadLanguages);
const theme = resolveTheme(stableTheme);
const shikiOptions = buildShikiOptions(languageId, theme, stableOpts);
return {
languageId,
langsToLoad,
themesToLoad: theme.themesToLoad,
shikiOptions
};
}, [
stableLang,
stableTheme,
stableOpts
]);
const requestIdRef = useRef(0);
const timeoutControl = useRef({
nextAllowedTime: 0,
timeoutId: void 0
});
const highlighterFactoryRef = useRef(highlighterFactory);
highlighterFactoryRef.current = highlighterFactory;
useEffect(() => {
const requestId = ++requestIdRef.current;
if (!resolved.languageId) return;
const run = async () => {
try {
const result = await highlight(code, resolved, stableOpts, highlighterFactoryRef.current);
if (requestId === requestIdRef.current) setHighlightedCode(result);
} catch (error) {
console.error("[react-shiki] highlight failed", error);
}
};
if (stableOpts.delay) throttleHighlighting(run, timeoutControl, stableOpts.delay);
else run();
return () => {
clearTimeout(timeoutControl.current.timeoutId);
};
}, [
code,
resolved,
stableOpts
]);
return highlightedCode;
};
//#endregion
//#region src/lib/plugins.ts
/**
* Rehype plugin to add an 'inline' property to <code> elements
* Sets 'inline' property to true if the <code> is not within a <pre> tag
*
* Pass this plugin to the `rehypePlugins` prop of react-markdown
* You can then access `inline` as a prop in components passed to react-markdown
*
* @example
* <ReactMarkdown rehypePlugins={[rehypeInlineCodeProperty]} />
*/
function rehypeInlineCodeProperty() {
return (tree) => {
visit(tree, "element", (node, _index, parent) => {
if (node.tagName === "code" && parent?.type === "element" && parent.tagName !== "pre") node.properties.inline = true;
});
};
}
/**
* Function to determine if code is inline based on the presence of line breaks
*
* @example
* const isInline = node && isInlineCode(node: Element)
*/
const isInlineCode = (node) => {
return !(node.children || []).filter((child) => child.type === "text").map((child) => child.value).join("").includes("\n");
};
//#endregion
//#region src/lib/component.tsx
/**
* Base ShikiHighlighter component factory.
* This creates a component that uses the provided hook implementation.
*/
const createShikiHighlighterComponent = (useShikiHighlighterImpl) => {
return forwardRef(({ language, theme, delay, transformers, defaultColor, cssVariablePrefix, addDefaultStyles = true, style, langStyle, className, langClassName, showLanguage = true, showLineNumbers = false, startingLineNumber = 1, children: code, as: Element = "div", customLanguages, preloadLanguages, ...shikiOptions }, ref) => {
const options = {
delay,
transformers,
customLanguages,
preloadLanguages,
showLineNumbers,
defaultColor,
cssVariablePrefix,
startingLineNumber,
...shikiOptions
};
const displayLanguageId = typeof language === "object" ? language.name || null : language?.trim() || null;
const highlightedCode = useShikiHighlighterImpl(code, language, theme, options);
return /* @__PURE__ */ jsxs(Element, {
ref,
"data-testid": "shiki-container",
"data-slot": "container",
className: clsx("rs-root", "not-prose", addDefaultStyles && "rs-default-styles", className),
style,
children: [showLanguage && displayLanguageId ? /* @__PURE__ */ jsx("span", {
id: "language-label",
"data-slot": "language-label",
className: clsx("rs-language-label", langClassName),
style: langStyle,
children: displayLanguageId
}) : null, typeof highlightedCode === "string" ? /* @__PURE__ */ jsx("div", {
"data-slot": "content",
dangerouslySetInnerHTML: { __html: highlightedCode }
}) : highlightedCode]
});
});
};
//#endregion
export { isLoadableLanguage as a, useHighlight as i, isInlineCode as n, rehypeInlineCodeProperty as r, createShikiHighlighterComponent as t };
//# sourceMappingURL=component-CYq7SnJ0.mjs.map