perf(plugin-shikiji): improve highlight

This commit is contained in:
pengzhanbo 2024-08-14 03:54:20 +08:00
parent 4a49b9f027
commit 199bbd9a9a
5 changed files with 202 additions and 183 deletions

View File

@ -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<string, string>()
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, '&#123;')
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
}
}
}

View File

@ -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
}

View File

@ -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<string, 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: Map<string, string>, twoslash: boolean) => {
mustaches.forEach((marker, match) => {
s = s.replaceAll(marker, match)
})
if (twoslash)
s = s.replace(/\{/g, '&#123;')
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<string, string>()
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
}
}
}

View File

@ -0,0 +1 @@
export * from './highlight.js'

View File

@ -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
}