UNPKG

react-shiki

Version:

Syntax highlighter component for react using shiki

344 lines (343 loc) 12.5 kB
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