mirror of https://github.com/vuejs/vitepress
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
230 lines
6.6 KiB
230 lines
6.6 KiB
import {
|
|
transformerCompactLineOptions,
|
|
transformerNotationDiff,
|
|
transformerNotationErrorLevel,
|
|
transformerNotationFocus,
|
|
transformerNotationHighlight,
|
|
type TransformerCompactLineOption
|
|
} from '@shikijs/transformers'
|
|
import { customAlphabet } from 'nanoid'
|
|
import c from 'picocolors'
|
|
import type { BundledLanguage, ShikiTransformer } from 'shiki'
|
|
import { createHighlighter, guessEmbeddedLanguages, isSpecialLang } from 'shiki'
|
|
import type { Logger } from 'vite'
|
|
import { isShell } from '../../shared'
|
|
import type { MarkdownOptions, ThemeOptions } from '../markdown'
|
|
|
|
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)
|
|
|
|
/**
|
|
* 2 steps:
|
|
*
|
|
* 1. convert attrs into line numbers:
|
|
* {4,7-13,16,23-27,40} -> [4,7,8,9,10,11,12,13,16,23,24,25,26,27,40]
|
|
* 2. convert line numbers into line options:
|
|
* [{ line: number, classes: string[] }]
|
|
*/
|
|
function attrsToLines(attrs: string): TransformerCompactLineOption[] {
|
|
attrs = attrs.replace(/^(?:\[.*?\])?.*?([\d,-]+).*/, '$1').trim()
|
|
const result: number[] = []
|
|
if (!attrs) {
|
|
return []
|
|
}
|
|
attrs
|
|
.split(',')
|
|
.map((v) => v.split('-').map((v) => parseInt(v, 10)))
|
|
.forEach(([start, end]) => {
|
|
if (start && end) {
|
|
result.push(
|
|
...Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
|
)
|
|
} else {
|
|
result.push(start)
|
|
}
|
|
})
|
|
return result.map((v) => ({
|
|
line: v,
|
|
classes: ['highlighted']
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Prevents the leading '$' symbol etc from being selectable/copyable. Also
|
|
* normalizes its syntax so there's no leading spaces, and only a single
|
|
* trailing space.
|
|
*
|
|
* NOTE: Any changes to this function may also need to update
|
|
* `src/client/app/composables/copyCode.ts`
|
|
*/
|
|
function transformerDisableShellSymbolSelect(): ShikiTransformer {
|
|
return {
|
|
name: 'vitepress:disable-shell-symbol-select',
|
|
tokens(tokensByLine) {
|
|
if (!isShell(this.options.lang)) return
|
|
|
|
for (const tokens of tokensByLine) {
|
|
if (tokens.length < 2) continue
|
|
|
|
// The first token should only be a symbol token
|
|
const firstTokenText = tokens[0].content.trim()
|
|
if (firstTokenText !== '$' && firstTokenText !== '>') continue
|
|
|
|
// The second token must have a leading space (separates the symbol)
|
|
if (tokens[1].content[0] !== ' ') continue
|
|
|
|
tokens[0].content = firstTokenText + ' '
|
|
tokens[0].htmlStyle ??= {}
|
|
tokens[0].htmlStyle['user-select'] = 'none'
|
|
tokens[0].htmlStyle['-webkit-user-select'] = 'none'
|
|
tokens[1].content = tokens[1].content.slice(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function highlight(
|
|
theme: ThemeOptions,
|
|
options: MarkdownOptions,
|
|
logger: Pick<Logger, 'warn'> = console
|
|
): Promise<
|
|
[(str: string, lang: string, attrs: string) => Promise<string>, () => void]
|
|
> {
|
|
const {
|
|
defaultHighlightLang: defaultLang = 'txt',
|
|
codeTransformers: userTransformers = []
|
|
} = options
|
|
|
|
const langAlias = Object.fromEntries(
|
|
Object.entries(options.languageAlias || {}) //
|
|
.map(([k, v]) => [k.toLowerCase(), v])
|
|
)
|
|
|
|
const highlighter = await createHighlighter({
|
|
themes:
|
|
typeof theme === 'object' && 'light' in theme && 'dark' in theme
|
|
? [theme.light, theme.dark]
|
|
: [theme],
|
|
langs: [...(options.languages || []), ...Object.values(langAlias)],
|
|
langAlias
|
|
})
|
|
|
|
await options?.shikiSetup?.(highlighter)
|
|
|
|
const transformers: ShikiTransformer[] = [
|
|
transformerNotationDiff(),
|
|
transformerNotationFocus({
|
|
classActiveLine: 'has-focus',
|
|
classActivePre: 'has-focused-lines'
|
|
}),
|
|
transformerNotationHighlight(),
|
|
transformerNotationErrorLevel(),
|
|
transformerDisableShellSymbolSelect(),
|
|
{
|
|
name: 'vitepress:add-dir',
|
|
pre(node) {
|
|
node.properties.dir = 'ltr'
|
|
}
|
|
}
|
|
]
|
|
|
|
// keep in sync with ./preWrapper.ts#extractLang
|
|
const langRE = /^[a-zA-Z0-9-_]+/
|
|
const vueRE = /-vue$/
|
|
|
|
return [
|
|
async (str: string, lang: string, attrs: string) => {
|
|
lang = langRE.exec(lang)?.[0].toLowerCase() || defaultLang
|
|
|
|
const vPre = !vueRE.test(lang)
|
|
if (!vPre) lang = lang.slice(0, -4)
|
|
|
|
try {
|
|
// https://github.com/shikijs/shiki/issues/952
|
|
if (
|
|
!isSpecialLang(lang) &&
|
|
!highlighter.getLoadedLanguages().includes(lang)
|
|
) {
|
|
await highlighter.loadLanguage(lang as any)
|
|
}
|
|
} catch {
|
|
logger.warn(
|
|
c.yellow(
|
|
`\nThe language '${lang}' is not loaded, falling back to '${defaultLang}' for syntax highlighting.`
|
|
)
|
|
)
|
|
lang = defaultLang
|
|
}
|
|
|
|
const lineOptions = attrsToLines(attrs)
|
|
const mustaches = new Map<string, string>()
|
|
|
|
const removeMustache = (s: string) => {
|
|
if (vPre) return s
|
|
return s.replace(/\{\{.*?\}\}/g, (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)
|
|
})
|
|
return s
|
|
}
|
|
|
|
str = removeMustache(str).trimEnd()
|
|
|
|
const embeddedLang = guessEmbeddedLanguages(str, lang, highlighter)
|
|
await highlighter.loadLanguage(...(embeddedLang as BundledLanguage[]))
|
|
|
|
const highlighted = highlighter.codeToHtml(str, {
|
|
lang,
|
|
transformers: [
|
|
...transformers,
|
|
transformerCompactLineOptions(lineOptions),
|
|
{
|
|
name: 'vitepress:v-pre',
|
|
pre(node) {
|
|
if (vPre) node.properties['v-pre'] = ''
|
|
}
|
|
},
|
|
{
|
|
name: 'vitepress:empty-line',
|
|
code(hast) {
|
|
hast.children.forEach((span) => {
|
|
if (
|
|
span.type === 'element' &&
|
|
span.tagName === 'span' &&
|
|
Array.isArray(span.properties.class) &&
|
|
span.properties.class.includes('line') &&
|
|
span.children.length === 0
|
|
) {
|
|
span.children.push({
|
|
type: 'element',
|
|
tagName: 'wbr',
|
|
properties: {},
|
|
children: []
|
|
})
|
|
}
|
|
})
|
|
}
|
|
},
|
|
...userTransformers
|
|
],
|
|
meta: { __raw: attrs },
|
|
...(typeof theme === 'object' && 'light' in theme && 'dark' in theme
|
|
? { themes: theme, defaultColor: false }
|
|
: { theme })
|
|
})
|
|
|
|
return restoreMustache(highlighted)
|
|
},
|
|
highlighter.dispose
|
|
]
|
|
}
|