react-shiki
Version:
Syntax highlighter component for react using shiki
348 lines (339 loc) • 12 kB
JavaScript
// 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