feat: 优化 shiki 插件

This commit is contained in:
pengzhanbo 2024-05-22 22:00:05 +08:00
parent 3d7199fe29
commit 4cdd51a2c6
33 changed files with 736 additions and 133 deletions

View File

@ -21,6 +21,10 @@ export default defineUserConfig({
pagePatterns: ['**/*.md', '!**/*.snippet.md', '!.vuepress', '!node_modules'],
markdown: {
code: false,
},
bundler: viteBundler(),
theme,

View File

@ -3,7 +3,7 @@
"type": "module",
"version": "1.0.0-rc.56",
"private": true,
"packageManager": "pnpm@9.1.1",
"packageManager": "pnpm@9.1.2",
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
"license": "MIT",
"keywords": [

View File

@ -2,6 +2,10 @@
使用 [`shiki`](https://shiki.style/) 为 Markdown 代码块启用代码高亮。
> [!WARNING]
> 相比于 官方的 [@vuepress/plugin-shiki](https://ecosystem.vuejs.press/zh/plugins/shiki.html)
> 本插件做了很多各种各样的调整,你可以认为这是试验性的。
## Install
```sh

View File

@ -39,6 +39,7 @@
"@shikijs/transformers": "^1.6.0",
"@shikijs/twoslash": "^1.6.0",
"@types/hast": "^3.0.4",
"@vuepress/helper": "2.0.0-rc.30",
"floating-vue": "^5.2.2",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm": "^3.0.0",

View File

@ -0,0 +1,58 @@
import { useClipboard, useEventListener } from '@vueuse/core'
const SHELL_RE = /language-(shellscript|shell|bash|sh|zsh)/
const IGNORE_NODES = ['.vp-copy-ignore', '.diff.remove']
interface CopyCodeOptions {
selector?: string
duration?: number
}
export function useCopyCode({
selector = 'div[class*="language-"] > button.copy',
duration = 2000,
}: CopyCodeOptions = {}): void {
if (__VUEPRESS_SSR__)
return
const timeoutIdMap = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>()
const { copy } = useClipboard({ legacy: true })
useEventListener('click', (e) => {
const el = e.target as HTMLElement
if (el.matches(selector)) {
const parent = el.parentElement
const sibling = el.nextElementSibling
if (!parent || !sibling)
return
const isShell = SHELL_RE.test(parent.className)
// Clone the node and remove the ignored nodes
const clone = sibling.cloneNode(true) as HTMLElement
clone
.querySelectorAll(IGNORE_NODES.join(','))
.forEach(node => node.remove())
let text = clone.textContent || ''
if (isShell)
text = text.replace(/^ *(\$|>) /gm, '').trim()
copy(text).then(() => {
if (duration <= 0)
return
el.classList.add('copied')
clearTimeout(timeoutIdMap.get(el))
const timeoutId = setTimeout(() => {
el.classList.remove('copied')
el.blur()
timeoutIdMap.delete(el)
}, duration)
timeoutIdMap.set(el, timeoutId)
})
}
})
}

View File

@ -0,0 +1,50 @@
import type { App } from 'vue'
import FloatingVue, { recomputeAllPoppers } from 'floating-vue'
import 'floating-vue/dist/style.css'
const isMobile = typeof navigator !== 'undefined' && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
export type FloatingVueConfig = Parameters<(typeof FloatingVue)['install']>[1]
export function enhanceTwoslash(app: App) {
if (typeof window !== 'undefined') {
// Recompute poppers when clicking on a tab
window.addEventListener('click', (e) => {
const path = e.composedPath()
if (path.some((el: any) => el?.classList?.contains?.('vp-code-group') || el?.classList?.contains?.('tabs')))
recomputeAllPoppers()
}, { passive: true })
}
app.use(FloatingVue, {
themes: {
'twoslash': {
$extend: 'dropdown',
triggers: isMobile ? ['touch'] : ['hover', 'touch'],
popperTriggers: isMobile ? ['touch'] : ['hover', 'touch'],
placement: 'bottom-start',
overflowPadding: 10,
delay: 0,
handleResize: false,
autoHide: true,
instantMove: true,
flip: false,
arrowPadding: 8,
autoBoundaryMaxSize: true,
},
'twoslash-query': {
$extend: 'twoslash',
triggers: ['click'],
popperTriggers: ['click'],
autoHide: false,
},
'twoslash-completion': {
$extend: 'twoslash-query',
triggers: ['click'],
popperTriggers: ['click'],
autoHide: false,
distance: 0,
arrowOverflow: true,
},
},
})
}

View File

@ -1,52 +0,0 @@
import { type ClientConfig, defineClientConfig } from 'vuepress/client'
import FloatingVue, { recomputeAllPoppers } from 'floating-vue'
import 'floating-vue/dist/style.css'
const isMobile = typeof navigator !== 'undefined' && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
export type FloatingVueConfig = Parameters<(typeof FloatingVue)['install']>[1]
export default defineClientConfig({
enhance({ app }) {
if (typeof window !== 'undefined') {
// Recompute poppers when clicking on a tab
window.addEventListener('click', (e) => {
const path = e.composedPath()
if (path.some((el: any) => el?.classList?.contains?.('vp-code-group') || el?.classList?.contains?.('tabs')))
recomputeAllPoppers()
}, { passive: true })
}
app.use(FloatingVue, {
themes: {
'twoslash': {
$extend: 'dropdown',
triggers: isMobile ? ['touch'] : ['hover', 'touch'],
popperTriggers: isMobile ? ['touch'] : ['hover', 'touch'],
placement: 'bottom-start',
overflowPadding: 10,
delay: 0,
handleResize: false,
autoHide: true,
instantMove: true,
flip: false,
arrowPadding: 8,
autoBoundaryMaxSize: true,
},
'twoslash-query': {
$extend: 'twoslash',
triggers: ['click'],
popperTriggers: ['click'],
autoHide: false,
},
'twoslash-completion': {
$extend: 'twoslash-query',
triggers: ['click'],
popperTriggers: ['click'],
autoHide: false,
distance: 0,
arrowOverflow: true,
},
},
})
},
}) as ClientConfig

View File

@ -0,0 +1,106 @@
import type { LocaleConfig } from 'vuepress'
import type { CopyCodeLocaleOptions } from '../types.js'
/** Multi language config for copy code button */
export const copyCodeButtonLocales: LocaleConfig<CopyCodeLocaleOptions>
= {
'/en/': {
title: 'Copy code',
copied: 'Copied',
},
'/zh/': {
title: '复制代码',
copied: '已复制',
},
'/zh-tw/': {
title: '複製代碼',
copied: '已複製',
},
'/de/': {
title: 'Kopiere den Code.',
copied: 'Kopiert',
},
'/de-at/': {
title: 'Kopiere den Code.',
copied: 'Kopierter',
},
'/vi/': {
title: 'Sao chép code',
copied: 'Đã sao chép',
},
'/uk/': {
title: 'Скопіюйте код',
copied: 'Скопійовано',
},
'/ru/': {
title: 'Скопировать код',
copied: 'Скопировано',
},
'/br/': {
title: 'Copiar o código',
copied: 'Código',
},
'/pl/': {
title: 'Skopiuj kod',
copied: 'Skopiowane',
},
'/sk/': {
title: 'Skopíruj kód',
copied: 'Skopírované',
},
'/fr/': {
title: 'Copier le code',
copied: 'Copié',
},
'/es/': {
title: 'Copiar código',
copied: 'Copiado',
},
'/ja/': {
title: 'コードをコピー',
copied: 'コピーしました',
},
'/tr/': {
title: 'Kodu kopyala',
copied: 'Kopyalandı',
},
'/ko/': {
title: '코드 복사',
copied: '복사됨',
},
'/fi/': {
title: 'Kopioi koodi',
copied: 'Kopioitu',
},
'/hu/': {
title: 'Kód másolása',
copied: 'Másolva',
},
'/id/': {
title: 'Salin kode',
copied: 'Disalin',
},
'/nl/': {
title: 'Kopieer code',
copied: 'Gekopieerd',
},
}

View File

@ -0,0 +1,28 @@
import type { App } from 'vuepress'
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import type { CopyCodeOptions } from '../types.js'
import { createCopyCodeButtonRender } from './createCopyCodeButtonRender.js'
/**
* This plugin should work after `preWrapperPlugin`,
* and if `preWrapper` is disabled, this plugin should not be called either.
*/
export function copyCodeButtonPlugin(md: Markdown, app: App, options?: boolean | CopyCodeOptions): void {
const render = createCopyCodeButtonRender(app, options)
if (!render)
return
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const [, , , env] = args
const result = fence(...args)
const { filePathRelative } = env as MarkdownEnv
// resolve copy code button
const copyCodeButton = render(filePathRelative ?? '')
return result.replace('><pre', `>${copyCodeButton}<pre`)
}
}

View File

@ -0,0 +1,40 @@
import {
getLocalePaths,
getRootLangPath,
isPlainObject,
} from '@vuepress/helper'
import type { App, LocaleConfig } from 'vuepress'
import { ensureLeadingSlash, resolveLocalePath } from 'vuepress/shared'
import type {
CopyCodeLocaleOptions,
CopyCodeOptions,
} from '../types.js'
import { copyCodeButtonLocales } from './copyCodeButtonLocales.js'
export function createCopyCodeButtonRender(app: App, options?: boolean | CopyCodeOptions): ((filePathRelative: string) => string) | null {
if (options === false)
return null
const { className = 'copy', locales: userLocales = {} }
= isPlainObject(options) ? options : {}
const root = getRootLangPath(app)
const locales: LocaleConfig<CopyCodeLocaleOptions> = {
// fallback locale
'/': userLocales['/'] || copyCodeButtonLocales[root],
}
getLocalePaths(app).forEach((path) => {
locales[path]
= userLocales[path] || copyCodeButtonLocales[path === '/' ? root : path]
})
return (filePathRelative) => {
const relativePath = ensureLeadingSlash(filePathRelative ?? '')
const localePath = resolveLocalePath(locales, relativePath)
const { title, copied } = locales[localePath] ?? {}
return `<button class="${className}" title="${title ?? 'Copy code'}" data-copied="${copied ?? 'Copied'}"></button>`
}
}

View File

@ -0,0 +1,3 @@
export * from './createCopyCodeButtonRender.js'
export * from './copyCodeButtonLocales.js'
export * from './copyCodeButtonPlugin.js'

View File

@ -9,6 +9,7 @@ import {
isSpecialLang,
} from 'shiki'
import {
transformerCompactLineOptions,
transformerNotationDiff,
transformerNotationErrorLevel,
transformerNotationFocus,
@ -17,9 +18,8 @@ import {
transformerRenderWhitespace,
} from '@shikijs/transformers'
import type { HighlighterOptions, ThemeOptions } from './types.js'
import { resolveAttrs } from './resolveAttrs.js'
import { LRUCache } from './lru.js'
import { defaultHoverInfoProcessor, transformerTwoslash } from './rendererTransformer.js'
import { LRUCache, attrsToLines } from './utils/index.js'
import { defaultHoverInfoProcessor, transformerTwoslash } from './twoslash/rendererTransformer.js'
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)
const cache = new LRUCache<string, string>(64)
@ -85,12 +85,13 @@ export async function highlight(
},
},
{
name: 'vuepress-shikiji:remove-escape',
name: 'vuepress:remove-escape',
postprocess: code => code.replace(RE_ESCAPE, '[!code'),
},
]
return (str: string, lang: string, attrs: string) => {
attrs = attrs || ''
lang = lang || defaultLang
const vPre = vueRE.test(lang) ? '' : 'v-pre'
@ -114,7 +115,8 @@ export async function highlight(
lang = defaultLang
}
}
const { attrs: attributes, rawAttrs } = resolveAttrs(attrs || '')
// const { attrs: attributes, rawAttrs } = resolveAttrs(attrs || '')
const enabledTwoslash = attrs.includes('twoslash')
const mustaches = new Map<string, string>()
const removeMustache = (s: string) => {
@ -133,7 +135,7 @@ export async function highlight(
s = s.replaceAll(marker, match)
})
if (attributes.twoslash && options.twoslash)
if (enabledTwoslash && options.twoslash)
s = s.replace(/{/g, '&#123;')
return `${s}\n`
@ -141,14 +143,14 @@ export async function highlight(
str = removeMustache(str).trimEnd()
const inlineTransformers: ShikiTransformer[] = []
const inlineTransformers: ShikiTransformer[] = [
transformerCompactLineOptions(attrsToLines(attrs)),
]
if (attributes.twoslash && options.twoslash) {
if (enabledTwoslash && options.twoslash) {
inlineTransformers.push(transformerTwoslash({
processHoverInfo(info) {
return defaultHoverInfoProcessor(info)
// Remove shiki_core namespace
.replace(/_shikijs_core[\w_]*\./g, '')
},
}))
}
@ -162,10 +164,7 @@ export async function highlight(
})
}
if (
(whitespace && attributes.whitespace !== false)
|| (!whitespace && attributes.whitespace)
)
if (attrs.includes('whitespace') || whitespace)
inlineTransformers.push(transformerRenderWhitespace({ position: 'boundary' }))
try {
@ -176,7 +175,7 @@ export async function highlight(
...inlineTransformers,
...userTransformers,
],
meta: { __raw: rawAttrs },
meta: { __raw: attrs },
...(typeof theme === 'object' && 'light' in theme && 'dark' in theme
? { themes: theme, defaultColor: false }
: { theme }),

View File

@ -0,0 +1,32 @@
// Modified from https://github.com/egoist/markdown-it-highlight-lines
// Now this plugin is only used to normalize line attrs.
// The else part of line highlights logic is in '../highlight.ts'.
import type { Markdown } from 'vuepress/markdown'
const HIGHLIGHT_LINES_REGEXP = /{([\d,-]+)}/
export function highlightLinesPlugin(md: Markdown): void {
const rawFence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const [tokens, idx] = args
const token = tokens[idx]
let lines: string | null = null
const rawInfo = token.info
const result = rawInfo?.match(HIGHLIGHT_LINES_REGEXP)
if (!result)
return rawFence(...args)
// ensure the next plugin get the correct lang
token.info = rawInfo.replace(HIGHLIGHT_LINES_REGEXP, '').trim()
lines = result[1]
token.info += ` ${lines}`
return rawFence(...args)
}
}

View File

@ -0,0 +1,3 @@
export * from './highlightLinesPlugin.js'
export * from './lineNumberPlugin.js'
export * from './preWrapperPlugin.js'

View File

@ -0,0 +1,56 @@
// markdown-it plugin for generating line numbers.
// It depends on preWrapper plugin.
import type { Markdown } from 'vuepress/markdown'
import type { LineNumberOptions } from '../types.js'
const LINE_NUMBERS_REGEXP = /:line-numbers\b/
const NO_LINE_NUMBERS_REGEXP = /:no-line-numbers\b/
export function lineNumberPlugin(md: Markdown, { lineNumbers = true }: LineNumberOptions = {}): void {
const rawFence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const rawCode = rawFence(...args)
const [tokens, idx] = args
const info = tokens[idx].info
const enableLineNumbers = LINE_NUMBERS_REGEXP.test(info)
const disableLineNumbers = NO_LINE_NUMBERS_REGEXP.test(info)
if (info.includes('twoslash'))
return rawCode
if (
(!lineNumbers && !enableLineNumbers)
|| (lineNumbers && disableLineNumbers)
)
return rawCode
const code = rawCode.slice(
rawCode.indexOf('<code>'),
rawCode.indexOf('</code>'),
)
const lines = code.split('\n')
if (
typeof lineNumbers === 'number'
&& lines.length < lineNumbers
&& !enableLineNumbers
)
return rawCode
const lineNumbersCode = [...Array(lines.length)]
.map(() => `<div class="line-number"></div>`)
.join('')
const lineNumbersWrapperCode = `<div class="line-numbers" aria-hidden="true">${lineNumbersCode}</div>`
const finalCode = rawCode
.replace(/<\/div>$/, `${lineNumbersWrapperCode}</div>`)
.replace(/"(language-[^"]*?)"/, '"$1 line-numbers-mode"')
return finalCode
}
}

View File

@ -0,0 +1,32 @@
// markdown-it plugin for generating line numbers.
// v-pre block logic is in `../highlight.ts`
import type { Markdown } from 'vuepress/markdown'
import type { PreWrapperOptions } from '../types.js'
import { resolveAttr, resolveLanguage } from '../utils/index.js'
export function preWrapperPlugin(md: Markdown, { preWrapper = true }: PreWrapperOptions = {}): void {
const rawFence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const [tokens, idx, options] = args
const token = tokens[idx]
// get token info
const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''
const lang = resolveLanguage(info)
const title = resolveAttr(info, 'title') || lang
const languageClass = `${options.langPrefix}${lang}`
let result = rawFence(...args)
if (!preWrapper) {
// remove `<code>` attributes
result = result.replace(/<code[^]*?>/, '<code>')
result = `<pre class="${languageClass}"${result.slice('<pre'.length)}`
return result
}
return `<div class="${languageClass}" data-ext="${lang}" data-title="${title}">${result}</div>`
}
}

View File

@ -0,0 +1,38 @@
import { ensureEndingSlash } from '@vuepress/helper'
import type { App } from 'vuepress'
import { getDirname, path } from 'vuepress/utils'
const __dirname = getDirname(import.meta.url)
const CLIENT_FOLDER = ensureEndingSlash(
path.resolve(__dirname, '../client'),
)
export async function prepareClientConfigFile(app: App, {
copyCode,
twoslash,
}: { copyCode: boolean, twoslash: boolean }): Promise<string> {
return await app.writeTemp(
'internal/plugin-shiki/client.js',
`\
${twoslash ? `import { enhanceTwoslash } from '${CLIENT_FOLDER}composables/twoslash.js'` : ''}
${copyCode ? `import { useCopyCode } from '${CLIENT_FOLDER}composables/copy-code.js'` : ''}
export default {
${twoslash
? `enhance({ app }) {
enhanceTwoslash(app)
},`
: ''}
${copyCode
? `setup() {
useCopyCode({
selector: __CC_SELECTOR__,
duration: __CC_DURATION__,
})
},`
: ''}
}
`,
)
}

View File

@ -1,44 +1,86 @@
import type { Plugin, PluginObject } from 'vuepress/core'
import { getDirname, path } from 'vuepress/utils'
import { isPlainObject } from 'vuepress/shared'
import { highlight } from './highlight.js'
import type { HighlighterOptions } from './types.js'
import type {
CopyCodeOptions,
HighlighterOptions,
LineNumberOptions,
PreWrapperOptions,
} from './types.js'
import {
highlightLinesPlugin,
lineNumberPlugin,
preWrapperPlugin,
} from './markdown/index.js'
import { copyCodeButtonPlugin } from './copy-code-button/index.js'
import { prepareClientConfigFile } from './prepareClientConfigFile.js'
export type ShikiPluginOptions = HighlighterOptions
export interface ShikiPluginOptions extends HighlighterOptions, LineNumberOptions, PreWrapperOptions {
/**
* Add copy code button
*
* @default true
*/
copyCode?: boolean | CopyCodeOptions
}
const __dirname = getDirname(import.meta.url)
export function shikiPlugin(options: ShikiPluginOptions = {}): Plugin {
const plugin: PluginObject = {
export function shikiPlugin({
preWrapper = true,
lineNumbers = true,
copyCode = true,
...options
}: ShikiPluginOptions = {}): Plugin {
const copyCodeOptions: CopyCodeOptions = isPlainObject(copyCode) ? copyCode : {}
return {
name: '@vuepress-plume/plugin-shikiji',
define: {
__CC_DURATION__: copyCodeOptions.duration ?? 2000,
__CC_SELECTOR__: `div[class*="language-"] > button.${copyCodeOptions.className || 'copy'}`,
},
clientConfigFile: app => prepareClientConfigFile(app, {
copyCode: copyCode !== false,
twoslash: options.twoslash ?? false,
}),
extendsMarkdown: async (md, app) => {
const theme = options.theme ?? { light: 'github-light', dark: 'github-dark' }
const highlighter = await highlight(theme, options, app.env.isDev)
md.options.highlight = highlighter
md.options.highlight = await highlight(theme, options, app.env.isDev)
md.use(highlightLinesPlugin)
md.use<PreWrapperOptions>(preWrapperPlugin, {
preWrapper,
})
if (preWrapper) {
copyCodeButtonPlugin(md, app, copyCode)
md.use<LineNumberOptions>(lineNumberPlugin, { lineNumbers })
}
},
}
if (!options.twoslash)
return plugin
return {
...plugin,
clientConfigFile: path.resolve(__dirname, '../client/config.js'),
extendsMarkdownOptions: (options) => {
if (options.code === false)
return
// 注入 floating-vue 后,需要关闭 代码块 的 v-pre 配置
if (options.code?.vPre) {
options.code.vPre.block = false
if (options.code !== false) {
if (options.code?.vPre) {
options.code.vPre.block = false
}
else {
options.code ??= {}
options.code.vPre = { block: false }
}
}
else {
options.code ??= {}
options.code.vPre = { block: false }
if ((options as any).vPre !== false) {
const vPre = isPlainObject((options as any).vPre) ? (options as any).vPre : { block: true }
if (vPre.block) {
(options as any).vPre ??= {}
;(options as any).vPre.block = false
}
}
},
}

View File

@ -19,8 +19,6 @@ export interface VitePressPluginTwoslashOptions extends TransformerTwoslashOptio
/**
* Create a Shiki transformer for VitePress to enable twoslash integration
*
* Add this to `markdown.codeTransformers` in `.vitepress/config.ts`
*/
export function transformerTwoslash(options: VitePressPluginTwoslashOptions = {}): ShikiTransformer {
const {
@ -52,7 +50,7 @@ export function transformerTwoslash(options: VitePressPluginTwoslashOptions = {}
return {
...twoslash,
name: '@shikijs/vuepress-twoslash',
name: '@shiki/vuepress-twoslash',
preprocess(code, options) {
const cleanup = options.transformers?.find(i => i.name === 'vuepress:clean-up')
if (cleanup)

View File

@ -5,6 +5,7 @@ import type {
ShikiTransformer,
ThemeRegistration,
} from 'shiki'
import type { LocaleConfig } from 'vuepress/shared'
export type ThemeOptions =
| ThemeRegistration
@ -72,3 +73,64 @@ export interface HighlighterOptions {
*/
whitespace?: boolean
}
export interface LineNumberOptions {
/**
* Show line numbers in code blocks
* @default true
*/
lineNumbers?: boolean | number
}
export interface PreWrapperOptions {
/**
* Wrap the `<pre>` tag with an extra `<div>` or not. Do not disable it unless you
* understand what's it for
*
* - Required for `lineNumbers`
* - Required for title display of default theme
*/
preWrapper?: boolean
}
/**
* Options for copy code button
*
* `<button title="{title}" class="{className}"></button>`
*/
export interface CopyCodeOptions {
/**
* Class name of the button
*
* @default 'copy'
*/
className?: string
/**
* Duration of the copied text
*
* @default 2000
*/
duration?: number
/**
* Locale config for copy code button
*/
locales?: LocaleConfig<CopyCodeLocaleOptions>
}
export interface CopyCodeLocaleOptions {
/**
* Title of the button
*
* @default 'Copy code'
*/
title?: string
/**
* Copied text
*
* @default 'Copied!'
*/
copied?: string
}

View File

@ -0,0 +1,37 @@
import type { TransformerCompactLineOption } from '@shikijs/transformers'
/**
* 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[] }]
*/
export 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 => Number.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(line => ({
line,
classes: ['highlighted'],
}))
}

View File

@ -0,0 +1,4 @@
export * from './attrsToLines.js'
export * from './resolveAttr.js'
export * from './resolveLanguage.js'
export * from './lru.js'

View File

@ -0,0 +1,9 @@
export function resolveAttr(info: string, attr: string): string | null {
// try to match specified attr mark
const pattern = `\\b${attr}\\s*=\\s*(?<quote>['"])(?<content>.+?)\\k<quote>(\\s|$)`
const regex = new RegExp(pattern, 'i')
const match = info.match(regex)
// return content if matched, null if not specified
return match?.groups?.content ?? null
}

View File

@ -0,0 +1,8 @@
const VUE_RE = /-vue$/
export function resolveLanguage(info: string): string {
return info
.match(/^([^ :[{]+)/)?.[1]
?.replace(VUE_RE, '')
.toLowerCase() ?? ''
}

View File

@ -2,6 +2,7 @@
"extends": "../tsconfig.build.json",
"compilerOptions": {
"rootDir": "./src",
"types": ["vuepress/client-types"],
"outDir": "./lib"
},
"include": ["./src"]

View File

@ -281,3 +281,78 @@ div[class*="language-"] {
filter: blur(0);
opacity: 1;
}
[class*="language-"] button.copy {
position: absolute;
top: 12px;
/* rtl:ignore */
right: 12px;
z-index: 3;
width: 40px;
height: 40px;
cursor: pointer;
background-color: var(--vp-code-copy-code-bg);
background-image: var(--vp-icon-copy);
background-repeat: no-repeat;
background-position: 50%;
background-size: 20px;
border: 1px solid var(--vp-code-copy-code-border-color);
border-radius: 4px;
opacity: 0;
transition:
border-color 0.25s,
background-color 0.25s,
opacity 0.25s;
/* rtl:ignore */
direction: ltr;
}
[class*="language-"]:hover > button.copy,
[class*="language-"] > button.copy:focus,
[class*="language-"] > button.copy.copied {
opacity: 1;
}
[class*="language-"] > button.copy:hover,
[class*="language-"] > button.copy.copied {
background-color: var(--vp-code-copy-code-hover-bg);
border-color: var(--vp-code-copy-code-hover-border-color);
}
[class*="language-"] > button.copy.copied,
[class*="language-"] > button.copy:hover.copied {
background-color: var(--vp-code-copy-code-hover-bg);
background-image: var(--vp-icon-copied);
/* rtl:ignore */
border-radius: 0 4px 4px 0;
}
[class*="language-"] > button.copy.copied::before,
[class*="language-"] > button.copy:hover.copied::before {
position: relative;
top: -1px;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
height: 40px;
padding: 0 10px;
font-size: 12px;
font-weight: 500;
color: var(--vp-code-copy-code-active-text);
text-align: center;
white-space: nowrap;
content: attr(data-copied);
background-color: var(--vp-code-copy-code-hover-bg);
border: 1px solid var(--vp-code-copy-code-hover-border-color);
/* rtl:ignore */
border-right: 0;
border-radius: 4px 0 0 4px;
/* rtl:ignore */
transform: translateX(calc(-100% - 1px));
}

View File

@ -20,28 +20,6 @@
--twoslash-tag-annotate-bg: var(--vp-c-green-soft);
}
div[class*="language-"].line-numbers-mode:has(> .twoslash) {
.line-numbers {
display: none;
}
pre {
padding-left: 1.5rem;
margin-left: 0;
}
}
@supports not selector(:has(a)) {
div[class*="language-"] .twoslash + .line-numbers {
display: none;
}
div[class*="language-"] pre.twoslash {
padding-left: 1.5rem;
margin-left: 0;
}
}
/* Respect people's wishes to not have animations */
@media (prefers-reduced-motion: reduce) {
.twoslash * {

View File

@ -355,12 +355,14 @@
--vp-code-line-warning-color: var(--vp-c-yellow-soft);
--vp-code-line-error-color: var(--vp-c-red-soft);
--vp-icon-copy: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' height='20' width='20' stroke='rgba(128,128,128,1)' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2'/%3E%3C/svg%3E");
--vp-icon-copied: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' height='20' width='20' stroke='rgba(128,128,128,1)' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2m-6 9 2 2 4-4'/%3E%3C/svg%3E");
--vp-code-copy-code-border-color: var(--vp-c-divider);
--vp-code-copy-code-bg: var(--vp-c-bg-soft);
--vp-code-copy-code-hover-border-color: var(--vp-c-divider);
--vp-code-copy-code-hover-bg: var(--vp-c-bg);
--vp-code-copy-code-active-text: var(--vp-c-text-2);
--vp-code-copy-copied-text-content: "Copied";
--vp-code-tab-divider: var(--vp-code-block-divider-color);
--vp-code-tab-text-color: var(--vp-c-text-2);
@ -370,10 +372,6 @@
--vp-code-tab-active-bar-color: var(--vp-c-brand-1);
}
html[lang="zh-CN"] {
--vp-code-copy-copied-text-content: "已复制";
}
/**
* Component: Button
* -------------------------------------------------------------------------- */

View File

@ -9,7 +9,6 @@ import { themeDataPlugin } from '@vuepress/plugin-theme-data'
import { autoFrontmatterPlugin } from '@vuepress-plume/plugin-auto-frontmatter'
import { baiduTongjiPlugin } from '@vuepress-plume/plugin-baidu-tongji'
import { blogDataPlugin } from '@vuepress-plume/plugin-blog-data'
import { copyCodePlugin } from '@vuepress-plume/plugin-copy-code'
import { iconifyPlugin } from '@vuepress-plume/plugin-iconify'
import { notesDataPlugin } from '@vuepress-plume/plugin-notes-data'
import { shikiPlugin } from '@vuepress-plume/plugin-shikiji'
@ -160,13 +159,6 @@ export function setupPlugins(
}))
}
if (options.copyCode !== false) {
plugins.push(copyCodePlugin({
selector: '.plume-content div[class*="language-"] pre',
...options.copyCode,
}))
}
if (options.markdownEnhance !== false) {
plugins.push(mdEnhancePlugin(
Object.assign(

View File

@ -2,7 +2,6 @@ import type { DocsearchOptions } from '@vuepress/plugin-docsearch'
import type { SearchPluginOptions } from '@vuepress-plume/plugin-search'
import type { AutoFrontmatterOptions } from '@vuepress-plume/plugin-auto-frontmatter'
import type { BaiduTongjiOptions } from '@vuepress-plume/plugin-baidu-tongji'
import type { CopyCodeOptions } from '@vuepress-plume/plugin-copy-code'
import type { ShikiPluginOptions } from '@vuepress-plume/plugin-shikiji'
import type { CommentPluginOptions } from '@vuepress/plugin-comment'
import type { MarkdownEnhancePluginOptions } from 'vuepress-plugin-md-enhance'
@ -48,8 +47,6 @@ export interface PlumeThemePluginOptions {
mediumZoom?: false
copyCode?: false | CopyCodeOptions
markdownEnhance?: false | MarkdownEnhancePluginOptions
markdownPower?: false | MarkdownPowerPluginOptions