diff --git a/plugins/plugin-shikiji/src/node/highlight.ts b/plugins/plugin-shikiji/src/node/highlight.ts deleted file mode 100644 index 49e1f69e6..000000000 --- a/plugins/plugin-shikiji/src/node/highlight.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { colors as c, logger } from 'vuepress/utils' -import { customAlphabet } from 'nanoid' -import type { ShikiTransformer } from 'shiki' -import { - addClassToHast, - bundledLanguages, - createHighlighter, - isPlainLang, - isSpecialLang, -} from 'shiki' -import { - transformerCompactLineOptions, - transformerNotationDiff, - transformerNotationErrorLevel, - transformerNotationFocus, - transformerNotationHighlight, - transformerNotationWordHighlight, - transformerRemoveNotationEscape, - transformerRenderWhitespace, -} from '@shikijs/transformers' -import type { HighlighterOptions, ThemeOptions } from './types.js' -import { attrsToLines, resolveLanguage } from './utils/index.js' -import { defaultHoverInfoProcessor, transformerTwoslash } from './twoslash/rendererTransformer.js' - -const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10) - -const vueRE = /-vue$/ -const mustacheRE = /\{\{.*?\}\}/g -const decorationsRE = /^\/\/ @decorations:(.*)\n/ - -export async function highlight( - theme: ThemeOptions, - options: HighlighterOptions, -): Promise<(str: string, lang: string, attrs: string) => string> { - const { - defaultHighlightLang: defaultLang = '', - codeTransformers: userTransformers = [], - whitespace = false, - languages = Object.keys(bundledLanguages), - } = options - - const highlighter = await createHighlighter({ - themes: - typeof theme === 'object' && 'light' in theme && 'dark' in theme - ? [theme.light, theme.dark] - : [theme], - langs: languages, - langAlias: options.languageAlias, - }) - - await options?.shikiSetup?.(highlighter) - - const transformers: ShikiTransformer[] = [ - transformerNotationDiff(), - transformerNotationFocus({ - classActiveLine: 'has-focus', - classActivePre: 'has-focused-lines', - }), - transformerNotationHighlight(), - transformerNotationErrorLevel(), - transformerNotationWordHighlight(), - { - name: 'vuepress:add-class', - pre(node) { - addClassToHast(node, 'vp-code') - }, - }, - { - name: 'vuepress:clean-up', - pre(node) { - delete node.properties.tabindex - delete node.properties.style - }, - }, - { - name: 'shiki:inline-decorations', - preprocess(code, options) { - code = code.replace(decorationsRE, (match, decorations) => { - options.decorations ||= [] - options.decorations.push(...JSON.parse(decorations)) - return '' - }) - return code - }, - }, - transformerRemoveNotationEscape(), - ] - - const loadedLanguages = highlighter.getLoadedLanguages() - - return (str: string, language: string, attrs: string) => { - attrs = attrs || '' - let lang = resolveLanguage(language) || defaultLang - const vPre = vueRE.test(lang) ? '' : 'v-pre' - - if (lang) { - const langLoaded = loadedLanguages.includes(lang as any) - if (!langLoaded && !isPlainLang(lang) && !isSpecialLang(lang)) { - logger.warn( - c.yellow( - `\nThe language '${lang}' is not loaded, falling back to '${defaultLang || 'txt' - }' for syntax highlighting.`, - ), - ) - lang = defaultLang - } - } - // const { attrs: attributes, rawAttrs } = resolveAttrs(attrs || '') - const enabledTwoslash = attrs.includes('twoslash') - const mustaches = new Map() - - const removeMustache = (s: string) => { - return s.replace(mustacheRE, (match) => { - let marker = mustaches.get(match) - if (!marker) { - marker = nanoid() - mustaches.set(match, marker) - } - return marker - }) - } - - const restoreMustache = (s: string) => { - mustaches.forEach((marker, match) => { - s = s.replaceAll(marker, match) - }) - - if (enabledTwoslash && options.twoslash) - s = s.replace(/\{/g, '{') - - return `${s}\n` - } - - str = removeMustache(str).trimEnd() - - const inlineTransformers: ShikiTransformer[] = [ - transformerCompactLineOptions(attrsToLines(attrs)), - ] - - if (enabledTwoslash && options.twoslash) { - inlineTransformers.push(transformerTwoslash({ - processHoverInfo(info) { - return defaultHoverInfoProcessor(info) - }, - })) - } - else { - inlineTransformers.push({ - name: 'vuepress:v-pre', - pre(node) { - if (vPre) - node.properties['v-pre'] = '' - }, - }) - } - - if (attrs.includes('whitespace') || whitespace) - inlineTransformers.push(transformerRenderWhitespace({ position: 'boundary' })) - - try { - const highlighted = highlighter.codeToHtml(str, { - lang, - transformers: [ - ...transformers, - ...inlineTransformers, - ...userTransformers, - ], - meta: { __raw: attrs }, - ...(typeof theme === 'object' && 'light' in theme && 'dark' in theme - ? { themes: theme, defaultColor: false } - : { theme }), - }) - - const rendered = restoreMustache(highlighted) - - return rendered - } - catch (e) { - logger.error(e) - return str - } - } -} diff --git a/plugins/plugin-shikiji/src/node/highlight/getLanguage.ts b/plugins/plugin-shikiji/src/node/highlight/getLanguage.ts new file mode 100644 index 000000000..b68196365 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/highlight/getLanguage.ts @@ -0,0 +1,25 @@ +import { isPlainLang, isSpecialLang } from 'shiki' +import { colors as c, logger } from 'vuepress/utils' +import { resolveLanguage } from '../utils/index.js' + +export function getLanguage( + loadedLanguages: string[], + language: string, + defaultLang: string, +): string { + let lang = resolveLanguage(language) || defaultLang + + if (lang) { + const langLoaded = loadedLanguages.includes(lang as any) + if (!langLoaded && !isPlainLang(lang) && !isSpecialLang(lang)) { + logger.warn( + c.yellow( + `\nThe language '${lang}' is not loaded, falling back to '${defaultLang || 'txt' + }' for syntax highlighting.`, + ), + ) + lang = defaultLang + } + } + return lang +} diff --git a/plugins/plugin-shikiji/src/node/highlight/highlight.ts b/plugins/plugin-shikiji/src/node/highlight/highlight.ts new file mode 100644 index 000000000..3796b79eb --- /dev/null +++ b/plugins/plugin-shikiji/src/node/highlight/highlight.ts @@ -0,0 +1,87 @@ +import { logger } from 'vuepress/utils' +import { customAlphabet } from 'nanoid' +import { bundledLanguages, createHighlighter } from 'shiki' +import type { HighlighterOptions, ThemeOptions } from '../types.js' +import { baseTransformers, getInlineTransformers } from './transformers.js' +import { getLanguage } from './getLanguage.js' + +const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10) +const mustacheRE = /\{\{.*?\}\}/g + +export async function highlight( + theme: ThemeOptions, + options: HighlighterOptions, +): Promise<(str: string, lang: string, attrs: string) => string> { + const { + defaultHighlightLang: defaultLang = '', + codeTransformers: userTransformers = [], + whitespace = false, + languages = Object.keys(bundledLanguages), + } = options + + const highlighter = await createHighlighter({ + themes: + typeof theme === 'object' && 'light' in theme && 'dark' in theme + ? [theme.light, theme.dark] + : [theme], + langs: languages, + langAlias: options.languageAlias, + }) + + await options.shikiSetup?.(highlighter) + + const loadedLanguages = highlighter.getLoadedLanguages() + const removeMustache = (s: string, mustaches: Map) => { + return s.replace(mustacheRE, (match) => { + let marker = mustaches.get(match) + if (!marker) { + marker = nanoid() + mustaches.set(match, marker) + } + return marker + }) + } + + const restoreMustache = (s: string, mustaches: Map, twoslash: boolean) => { + mustaches.forEach((marker, match) => { + s = s.replaceAll(marker, match) + }) + + if (twoslash) + s = s.replace(/\{/g, '{') + + return `${s}\n` + } + + return (str: string, language: string, attrs: string = '') => { + const lang = getLanguage(loadedLanguages, language, defaultLang) + + const enabledTwoslash = attrs.includes('twoslash') && !!options.twoslash + + const mustaches = new Map() + str = removeMustache(str, mustaches).trimEnd() + + try { + const highlighted = highlighter.codeToHtml(str, { + lang, + transformers: [ + ...baseTransformers, + ...getInlineTransformers({ attrs, lang, enabledTwoslash, whitespace }), + ...userTransformers, + ], + meta: { __raw: attrs }, + ...(typeof theme === 'object' && 'light' in theme && 'dark' in theme + ? { themes: theme, defaultColor: false } + : { theme }), + }) + + const rendered = restoreMustache(highlighted, mustaches, enabledTwoslash) + + return rendered + } + catch (e) { + logger.error(e) + return str + } + } +} diff --git a/plugins/plugin-shikiji/src/node/highlight/index.ts b/plugins/plugin-shikiji/src/node/highlight/index.ts new file mode 100644 index 000000000..80f96a386 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/highlight/index.ts @@ -0,0 +1 @@ +export * from './highlight.js' diff --git a/plugins/plugin-shikiji/src/node/highlight/transformers.ts b/plugins/plugin-shikiji/src/node/highlight/transformers.ts new file mode 100644 index 000000000..d3cf5a843 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/highlight/transformers.ts @@ -0,0 +1,89 @@ +import type { ShikiTransformer } from 'shiki' +import { addClassToHast } from 'shiki' +import { + transformerCompactLineOptions, + transformerNotationDiff, + transformerNotationErrorLevel, + transformerNotationFocus, + transformerNotationHighlight, + transformerNotationWordHighlight, + transformerRemoveNotationEscape, + transformerRenderWhitespace, +} from '@shikijs/transformers' +import type { WhitespacePosition } from '../utils/index.js' +import { attrsToLines, resolveWhitespacePosition } from '../utils/index.js' +import { defaultHoverInfoProcessor, transformerTwoslash } from '../twoslash/rendererTransformer.js' + +const decorationsRE = /^\/\/ @decorations:(.*)\n/ + +export const baseTransformers: ShikiTransformer[] = [ + transformerNotationDiff(), + transformerNotationFocus({ + classActiveLine: 'has-focus', + classActivePre: 'has-focused-lines', + }), + transformerNotationHighlight(), + transformerNotationErrorLevel(), + transformerNotationWordHighlight(), + { + name: 'vuepress:add-class', + pre(node) { + addClassToHast(node, 'vp-code') + }, + }, + { + name: 'vuepress:clean-up', + pre(node) { + delete node.properties.tabindex + delete node.properties.style + }, + }, + { + name: 'shiki:inline-decorations', + preprocess(code, options) { + code = code.replace(decorationsRE, (match, decorations) => { + options.decorations ||= [] + options.decorations.push(...JSON.parse(decorations)) + return '' + }) + return code + }, + }, + transformerRemoveNotationEscape(), +] + +const vueRE = /-vue$/ +export function getInlineTransformers({ attrs, lang, enabledTwoslash, whitespace }: { + attrs: string + lang: string + enabledTwoslash: boolean + whitespace: boolean | WhitespacePosition +}): ShikiTransformer[] { + const vPre = vueRE.test(lang) ? '' : 'v-pre' + const inlineTransformers: ShikiTransformer[] = [ + transformerCompactLineOptions(attrsToLines(attrs)), + ] + + if (enabledTwoslash) { + inlineTransformers.push(transformerTwoslash({ + processHoverInfo(info) { + return defaultHoverInfoProcessor(info) + }, + })) + } + else { + inlineTransformers.push({ + name: 'vuepress:v-pre', + pre(node) { + if (vPre) + node.properties['v-pre'] = '' + }, + }) + } + + const position = resolveWhitespacePosition(attrs, whitespace) + if (position) + inlineTransformers.push(transformerRenderWhitespace({ position })) + + return inlineTransformers +}