import type { Properties } from 'hast'; import { bundledLanguages, createCssVariablesTheme, getHighlighter } from 'shikiji'; import { visit } from 'unist-util-visit'; import type { ShikiConfig } from './types.js'; export interface ShikiHighlighter { highlight(code: string, lang?: string, options?: { inline?: boolean }): string; } // TODO: Remove this special replacement in Astro 5 const ASTRO_COLOR_REPLACEMENTS: Record = { '--astro-code-foreground': '--astro-code-color-text', '--astro-code-background': '--astro-code-color-background', }; const COLOR_REPLACEMENT_REGEX = new RegExp( `${Object.keys(ASTRO_COLOR_REPLACEMENTS).join('|')}`, 'g' ); let _cssVariablesTheme: ReturnType; const cssVariablesTheme = () => _cssVariablesTheme ?? (_cssVariablesTheme = createCssVariablesTheme({ variablePrefix: '--astro-code-' })); export async function createShikiHighlighter({ langs = [], theme = 'github-dark', experimentalThemes = {}, wrap = false, transformers = [], }: ShikiConfig = {}): Promise { const themes = experimentalThemes; theme = theme === 'css-variables' ? cssVariablesTheme() : theme; const highlighter = await getHighlighter({ langs: langs.length ? langs : Object.keys(bundledLanguages), themes: Object.values(themes).length ? Object.values(themes) : [theme], }); const loadedLanguages = highlighter.getLoadedLanguages(); return { highlight(code, lang = 'plaintext', options) { if (lang !== 'plaintext' && !loadedLanguages.includes(lang)) { // eslint-disable-next-line no-console console.warn(`[Shiki] The language "${lang}" doesn't exist, falling back to "plaintext".`); lang = 'plaintext'; } const themeOptions = Object.values(themes).length ? { themes } : { theme }; const inline = options?.inline ?? false; return highlighter.codeToHtml(code, { ...themeOptions, lang, transformers: [ { pre(node) { // Swap to `code` tag if inline if (inline) { node.tagName = 'code'; } const classValue = normalizePropAsString(node.properties.class) ?? ''; const styleValue = normalizePropAsString(node.properties.style) ?? ''; // Replace "shiki" class naming with "astro-code" node.properties.class = classValue.replace(/shiki/g, 'astro-code'); // Handle code wrapping // if wrap=null, do nothing. if (wrap === false) { node.properties.style = styleValue + '; overflow-x: auto;'; } else if (wrap === true) { node.properties.style = styleValue + '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'; } }, line(node) { // Add "user-select: none;" for "+"/"-" diff symbols. // Transform `+ something // into `+ something` if (lang === 'diff') { const innerSpanNode = node.children[0]; const innerSpanTextNode = innerSpanNode?.type === 'element' && innerSpanNode.children?.[0]; if (innerSpanTextNode && innerSpanTextNode.type === 'text') { const start = innerSpanTextNode.value[0]; if (start === '+' || start === '-') { innerSpanTextNode.value = innerSpanTextNode.value.slice(1); innerSpanNode.children.unshift({ type: 'element', tagName: 'span', properties: { style: 'user-select: none;' }, children: [{ type: 'text', value: start }], }); } } } }, code(node) { if (inline) { return node.children[0] as typeof node; } }, root(node) { if (Object.values(experimentalThemes).length) { return; } // theme.id for shiki -> shikiji compat const themeName = typeof theme === 'string' ? theme : theme.name; if (themeName === 'css-variables') { // Replace special color tokens to CSS variables visit(node as any, 'element', (child) => { if (child.properties?.style) { child.properties.style = replaceCssVariables(child.properties.style); } }); } }, }, ...transformers, ], }); }, }; } function normalizePropAsString(value: Properties[string]): string | null { return Array.isArray(value) ? value.join(' ') : (value as string | null); } /** * shiki -> shikiji compat as we need to manually replace it * @internal Exported for error overlay use only */ export function replaceCssVariables(str: string) { return str.replace(COLOR_REPLACEMENT_REGEX, (match) => ASTRO_COLOR_REPLACEMENTS[match] || match); }