UNPKG

react-shiki

Version:

Syntax highlighter component for react using shiki

348 lines (339 loc) 12 kB
// src/lib/hook.ts import { useEffect, useMemo, useRef as useRef2, useState } from "react"; import { jsx, jsxs, Fragment } from "react/jsx-runtime"; import { toJsxRuntime } from "hast-util-to-jsx-runtime"; // src/lib/utils.ts import { useRef } from "react"; import { dequal } from "dequal"; var useStableOptions = (value) => { const ref = useRef(value); const revision = useRef(0); if (typeof value !== "object" || value === null) { if (value !== ref.current) { ref.current = value; revision.current += 1; } return [value, revision.current]; } if (value !== ref.current) { if (!dequal(value, ref.current)) { ref.current = value; revision.current += 1; } } return [ref.current, revision.current]; }; var throttleHighlighting = (performHighlight, timeoutControl, throttleMs) => { const now = Date.now(); clearTimeout(timeoutControl.current.timeoutId); const delay = Math.max(0, timeoutControl.current.nextAllowedTime - now); timeoutControl.current.timeoutId = setTimeout(() => { performHighlight().catch(console.error); timeoutControl.current.nextAllowedTime = now + throttleMs; }, delay); }; // src/lib/resolvers.ts var resolveLanguage = (lang, customLanguages, langAliases) => { const normalizedCustomLangs = customLanguages ? Array.isArray(customLanguages) ? customLanguages : [customLanguages] : []; if (lang == null || typeof lang === "string" && !lang.trim()) { return { languageId: "plaintext", displayLanguageId: "plaintext", langsToLoad: void 0 }; } if (typeof lang === "object") { return { languageId: lang.name, displayLanguageId: lang.name || null, langsToLoad: lang }; } const lowerLang = lang.toLowerCase(); const matches = (str) => str?.toLowerCase() === lowerLang; const customMatch = normalizedCustomLangs.find( (cl) => matches(cl.name) || matches(cl.scopeName) || matches(cl.scopeName?.split(".").pop()) || cl.aliases?.some(matches) || cl.fileTypes?.some(matches) ); if (customMatch) { return { languageId: customMatch.name || lang, displayLanguageId: lang, langsToLoad: customMatch }; } if (langAliases?.[lang]) { return { languageId: langAliases[lang], displayLanguageId: lang, langsToLoad: langAliases[lang] }; } return { languageId: lang, displayLanguageId: lang, langsToLoad: lang }; }; function resolveTheme(themeInput) { const isTextmateTheme = typeof themeInput === "object" && "tokenColors" in themeInput && Array.isArray(themeInput.tokenColors); const isMultiThemeConfig = typeof themeInput === "object" && themeInput !== null && !isTextmateTheme; const validMultiThemeObj = typeof themeInput === "object" && themeInput !== null && !isTextmateTheme && Object.entries(themeInput).some( ([key, value]) => key && value && key.trim() !== "" && value !== "" && (typeof value === "string" || isTextmateTheme) ); if (isMultiThemeConfig) { const themeId = validMultiThemeObj ? `multi-${Object.values(themeInput).map( (theme) => (typeof theme === "string" ? theme : theme?.name) || "custom" ).sort().join("-")}` : "multi-default"; return { isMultiTheme: true, themeId, multiTheme: validMultiThemeObj ? themeInput : null, themesToLoad: validMultiThemeObj ? Object.values(themeInput) : [] }; } return { isMultiTheme: false, themeId: typeof themeInput === "string" ? themeInput : themeInput?.name || "custom", singleTheme: themeInput, themesToLoad: [themeInput] }; } // src/lib/transformers.ts function lineNumbersTransformer(startLine = 1) { return { name: "react-shiki:line-numbers", code(node) { this.addClassToHast(node, "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, "line-numbers"); return node; } }; } // src/lib/hook.ts var DEFAULT_THEMES = { light: "github-light", dark: "github-dark" }; var useShikiHighlighter = (code, lang, themeInput, options = {}, highlighterFactory) => { const [highlightedCode, setHighlightedCode] = useState(null); const [stableLang, langRev] = useStableOptions(lang); const [stableTheme, themeRev] = useStableOptions(themeInput); const [stableOpts, optsRev] = useStableOptions(options); const { languageId, langsToLoad } = useMemo( () => resolveLanguage( stableLang, stableOpts.customLanguages, stableOpts.langAlias ), [stableLang, stableOpts.customLanguages, stableOpts.langAlias] ); const { isMultiTheme, themeId, multiTheme, singleTheme, themesToLoad } = useMemo(() => resolveTheme(stableTheme), [stableTheme]); const timeoutControl = useRef2({ nextAllowedTime: 0, timeoutId: void 0 }); const shikiOptions = useMemo(() => { const languageOption = { lang: languageId }; const { defaultColor, cssVariablePrefix, showLineNumbers, startingLineNumber, ...restOptions } = stableOpts; const themeOptions = isMultiTheme ? { themes: multiTheme || DEFAULT_THEMES, defaultColor, cssVariablePrefix } : { theme: singleTheme || DEFAULT_THEMES.dark }; const transformers = restOptions.transformers || []; if (showLineNumbers) { transformers.push(lineNumbersTransformer(startingLineNumber)); } return { ...languageOption, ...themeOptions, ...restOptions, transformers }; }, [languageId, themeId, langRev, themeRev, optsRev]); useEffect(() => { let isMounted = true; const highlightCode = async () => { if (!languageId) return; const highlighter = stableOpts.highlighter ? stableOpts.highlighter : await highlighterFactory( langsToLoad, themesToLoad, stableOpts.engine ); const loadedLanguages = highlighter.getLoadedLanguages(); const langToUse = loadedLanguages.includes(languageId) ? languageId : "plaintext"; const finalOptions = { ...shikiOptions, lang: langToUse }; if (isMounted) { const output = stableOpts.outputFormat === "html" ? highlighter.codeToHtml(code, finalOptions) : toJsxRuntime(highlighter.codeToHast(code, finalOptions), { jsx, jsxs, Fragment }); setHighlightedCode(output); } }; const { delay } = stableOpts; if (delay) { throttleHighlighting(highlightCode, timeoutControl, delay); } else { highlightCode().catch(console.error); } return () => { isMounted = false; clearTimeout(timeoutControl.current.timeoutId); }; }, [ code, shikiOptions, stableOpts.delay, stableOpts.highlighter, langsToLoad, themesToLoad ]); return highlightedCode; }; // src/lib/plugins.ts import { visit } from "unist-util-visit"; function rehypeInlineCodeProperty() { return (tree) => { visit(tree, "element", (node, _index, parent) => { if (node.tagName === "code" && parent.tagName !== "pre") { node.properties.inline = true; } }); }; } var isInlineCode = (node) => { const textContent = (node.children || []).filter((child) => child.type === "text").map((child) => child.value).join(""); return !textContent.includes("\n"); }; // #style-inject:#style-inject function styleInject(css, { insertAt } = {}) { if (!css || typeof document === "undefined") return; const head = document.head || document.getElementsByTagName("head")[0]; const style = document.createElement("style"); style.type = "text/css"; if (insertAt === "top") { if (head.firstChild) { head.insertBefore(style, head.firstChild); } else { head.appendChild(style); } } else { head.appendChild(style); } if (style.styleSheet) { style.styleSheet.cssText = css; } else { style.appendChild(document.createTextNode(css)); } } // src/lib/styles.css styleInject(".relative {\n position: relative;\n}\n.defaultStyles pre {\n overflow: auto;\n border-radius: 0.5rem;\n padding-left: 1.5rem;\n padding-right: 1.5rem;\n padding-top: 1.25rem;\n padding-bottom: 1.25rem;\n}\n.languageLabel {\n position: absolute;\n right: 0.75rem;\n top: 0.5rem;\n font-family: monospace;\n font-size: 0.75rem;\n letter-spacing: -0.05em;\n color: rgba(107, 114, 128, 0.85);\n}\n.line-numbers::before {\n counter-increment: line-number;\n content: counter(line-number);\n display: inline-flex;\n justify-content: flex-end;\n align-items: flex-start;\n box-sizing: content-box;\n min-width: var(--line-numbers-width, 2ch);\n padding-left: var(--line-numbers-padding-left, 2ch);\n padding-right: var(--line-numbers-padding-right, 2ch);\n color: var(--line-numbers-foreground, rgba(107, 114, 128, 0.6));\n font-size: var(--line-numbers-font-size, inherit);\n font-weight: var(--line-numbers-font-weight, inherit);\n line-height: var(--line-numbers-line-height, inherit);\n font-family: var(--line-numbers-font-family, inherit);\n opacity: var(--line-numbers-opacity, 1);\n user-select: none;\n pointer-events: none;\n}\n.has-line-numbers {\n counter-reset: line-number calc(var(--line-start, 1) - 1);\n --line-numbers-foreground: rgba(107, 114, 128, 0.5);\n --line-numbers-width: 2ch;\n --line-numbers-padding-left: 0ch;\n --line-numbers-padding-right: 2ch;\n --line-numbers-font-size: inherit;\n --line-numbers-font-weight: inherit;\n --line-numbers-line-height: inherit;\n --line-numbers-font-family: inherit;\n --line-numbers-opacity: 1;\n}\n"); // src/lib/component.tsx import { clsx } from "clsx"; import { forwardRef } from "react"; import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime"; var 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 = "pre", customLanguages, ...shikiOptions }, ref) => { const options = { delay, transformers, customLanguages, showLineNumbers, defaultColor, cssVariablePrefix, startingLineNumber, ...shikiOptions }; const { displayLanguageId } = resolveLanguage( language, customLanguages ); const highlightedCode = useShikiHighlighterImpl( code, language, theme, options ); const isHtmlOutput = typeof highlightedCode === "string"; return /* @__PURE__ */ jsxs2( Element, { ref, "data-testid": "shiki-container", className: clsx( "relative", "not-prose", addDefaultStyles && "defaultStyles", className ), style, id: "shiki-container", children: [ showLanguage && displayLanguageId ? /* @__PURE__ */ jsx2( "span", { className: clsx("languageLabel", langClassName), style: langStyle, id: "language-label", children: displayLanguageId } ) : null, isHtmlOutput ? /* @__PURE__ */ jsx2("div", { dangerouslySetInnerHTML: { __html: highlightedCode } }) : highlightedCode ] } ); } ); }; export { useShikiHighlighter, rehypeInlineCodeProperty, isInlineCode, createShikiHighlighterComponent }; //# sourceMappingURL=chunk-SCXX26KJ.js.map