mirror of
https://github.com/pengzhanbo/vuepress-theme-plume.git
synced 2026-04-23 10:58:13 +08:00
feat: 优化 shiki 插件
This commit is contained in:
parent
3d7199fe29
commit
4cdd51a2c6
@ -21,6 +21,10 @@ export default defineUserConfig({
|
||||
|
||||
pagePatterns: ['**/*.md', '!**/*.snippet.md', '!.vuepress', '!node_modules'],
|
||||
|
||||
markdown: {
|
||||
code: false,
|
||||
},
|
||||
|
||||
bundler: viteBundler(),
|
||||
|
||||
theme,
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
使用 [`shiki`](https://shiki.style/) 为 Markdown 代码块启用代码高亮。
|
||||
|
||||
> [!WARNING]
|
||||
> 相比于 官方的 [@vuepress/plugin-shiki](https://ecosystem.vuejs.press/zh/plugins/shiki.html),
|
||||
> 本插件做了很多各种各样的调整,你可以认为这是试验性的。
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
|
||||
@ -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",
|
||||
|
||||
58
plugins/plugin-shikiji/src/client/composables/copy-code.ts
Normal file
58
plugins/plugin-shikiji/src/client/composables/copy-code.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
50
plugins/plugin-shikiji/src/client/composables/twoslash.ts
Normal file
50
plugins/plugin-shikiji/src/client/composables/twoslash.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
@ -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`)
|
||||
}
|
||||
}
|
||||
@ -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>`
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from './createCopyCodeButtonRender.js'
|
||||
export * from './copyCodeButtonLocales.js'
|
||||
export * from './copyCodeButtonPlugin.js'
|
||||
@ -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, '{')
|
||||
|
||||
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 }),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
3
plugins/plugin-shikiji/src/node/markdown/index.ts
Normal file
3
plugins/plugin-shikiji/src/node/markdown/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './highlightLinesPlugin.js'
|
||||
export * from './lineNumberPlugin.js'
|
||||
export * from './preWrapperPlugin.js'
|
||||
56
plugins/plugin-shikiji/src/node/markdown/lineNumberPlugin.ts
Normal file
56
plugins/plugin-shikiji/src/node/markdown/lineNumberPlugin.ts
Normal 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
|
||||
}
|
||||
}
|
||||
32
plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts
Normal file
32
plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts
Normal 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>`
|
||||
}
|
||||
}
|
||||
38
plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts
Normal file
38
plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts
Normal 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__,
|
||||
})
|
||||
},`
|
||||
: ''}
|
||||
}
|
||||
`,
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
}
|
||||
|
||||
37
plugins/plugin-shikiji/src/node/utils/attrsToLines.ts
Normal file
37
plugins/plugin-shikiji/src/node/utils/attrsToLines.ts
Normal 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'],
|
||||
}))
|
||||
}
|
||||
4
plugins/plugin-shikiji/src/node/utils/index.ts
Normal file
4
plugins/plugin-shikiji/src/node/utils/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './attrsToLines.js'
|
||||
export * from './resolveAttr.js'
|
||||
export * from './resolveLanguage.js'
|
||||
export * from './lru.js'
|
||||
9
plugins/plugin-shikiji/src/node/utils/resolveAttr.ts
Normal file
9
plugins/plugin-shikiji/src/node/utils/resolveAttr.ts
Normal 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
|
||||
}
|
||||
8
plugins/plugin-shikiji/src/node/utils/resolveLanguage.ts
Normal file
8
plugins/plugin-shikiji/src/node/utils/resolveLanguage.ts
Normal file
@ -0,0 +1,8 @@
|
||||
const VUE_RE = /-vue$/
|
||||
|
||||
export function resolveLanguage(info: string): string {
|
||||
return info
|
||||
.match(/^([^ :[{]+)/)?.[1]
|
||||
?.replace(VUE_RE, '')
|
||||
.toLowerCase() ?? ''
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
"extends": "../tsconfig.build.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"types": ["vuepress/client-types"],
|
||||
"outDir": "./lib"
|
||||
},
|
||||
"include": ["./src"]
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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 * {
|
||||
|
||||
@ -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
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user