From 4cdd51a2c6bf145be83c43e7e2ffd320e1650596 Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Wed, 22 May 2024 22:00:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20shiki=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vuepress/config.ts | 4 + package.json | 2 +- plugins/plugin-shikiji/README.md | 4 + plugins/plugin-shikiji/package.json | 1 + .../src/client/composables/copy-code.ts | 58 ++++++++++ .../src/client/composables/twoslash.ts | 50 +++++++++ plugins/plugin-shikiji/src/client/config.ts | 52 --------- .../copy-code-button/copyCodeButtonLocales.ts | 106 ++++++++++++++++++ .../copy-code-button/copyCodeButtonPlugin.ts | 28 +++++ .../createCopyCodeButtonRender.ts | 40 +++++++ .../src/node/copy-code-button/index.ts | 3 + plugins/plugin-shikiji/src/node/highlight.ts | 29 +++-- .../src/node/markdown/highlightLinesPlugin.ts | 32 ++++++ .../plugin-shikiji/src/node/markdown/index.ts | 3 + .../src/node/markdown/lineNumberPlugin.ts | 56 +++++++++ .../src/node/markdown/preWrapperPlugin.ts | 32 ++++++ .../src/node/prepareClientConfigFile.ts | 38 +++++++ .../plugin-shikiji/src/node/shikiPlugin.ts | 90 +++++++++++---- .../{ => twoslash}/renderer-floating-vue.ts | 0 .../{ => twoslash}/rendererTransformer.ts | 4 +- plugins/plugin-shikiji/src/node/types.ts | 62 ++++++++++ .../src/node/utils/attrsToLines.ts | 37 ++++++ .../plugin-shikiji/src/node/utils/index.ts | 4 + .../src/node/{ => utils}/lru.ts | 0 .../src/node/utils/resolveAttr.ts | 9 ++ .../src/node/{ => utils}/resolveAttrs.ts | 0 .../src/node/utils/resolveLanguage.ts | 8 ++ plugins/plugin-shikiji/tsconfig.build.json | 1 + theme/src/client/styles/code.scss | 75 +++++++++++++ theme/src/client/styles/twoslash.scss | 22 ---- theme/src/client/styles/vars.scss | 8 +- theme/src/node/plugins.ts | 8 -- theme/src/shared/options/plugins.ts | 3 - 33 files changed, 736 insertions(+), 133 deletions(-) create mode 100644 plugins/plugin-shikiji/src/client/composables/copy-code.ts create mode 100644 plugins/plugin-shikiji/src/client/composables/twoslash.ts delete mode 100644 plugins/plugin-shikiji/src/client/config.ts create mode 100644 plugins/plugin-shikiji/src/node/copy-code-button/copyCodeButtonLocales.ts create mode 100644 plugins/plugin-shikiji/src/node/copy-code-button/copyCodeButtonPlugin.ts create mode 100644 plugins/plugin-shikiji/src/node/copy-code-button/createCopyCodeButtonRender.ts create mode 100644 plugins/plugin-shikiji/src/node/copy-code-button/index.ts create mode 100644 plugins/plugin-shikiji/src/node/markdown/highlightLinesPlugin.ts create mode 100644 plugins/plugin-shikiji/src/node/markdown/index.ts create mode 100644 plugins/plugin-shikiji/src/node/markdown/lineNumberPlugin.ts create mode 100644 plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts create mode 100644 plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts rename plugins/plugin-shikiji/src/node/{ => twoslash}/renderer-floating-vue.ts (100%) rename plugins/plugin-shikiji/src/node/{ => twoslash}/rendererTransformer.ts (95%) create mode 100644 plugins/plugin-shikiji/src/node/utils/attrsToLines.ts create mode 100644 plugins/plugin-shikiji/src/node/utils/index.ts rename plugins/plugin-shikiji/src/node/{ => utils}/lru.ts (100%) create mode 100644 plugins/plugin-shikiji/src/node/utils/resolveAttr.ts rename plugins/plugin-shikiji/src/node/{ => utils}/resolveAttrs.ts (100%) create mode 100644 plugins/plugin-shikiji/src/node/utils/resolveLanguage.ts diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index 14751530..ec1a416a 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -21,6 +21,10 @@ export default defineUserConfig({ pagePatterns: ['**/*.md', '!**/*.snippet.md', '!.vuepress', '!node_modules'], + markdown: { + code: false, + }, + bundler: viteBundler(), theme, diff --git a/package.json b/package.json index 8a800fc1..4d79f802 100644 --- a/package.json +++ b/package.json @@ -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 (https://github.com/pengzhanbo/)", "license": "MIT", "keywords": [ diff --git a/plugins/plugin-shikiji/README.md b/plugins/plugin-shikiji/README.md index 4d120039..f4c751d5 100644 --- a/plugins/plugin-shikiji/README.md +++ b/plugins/plugin-shikiji/README.md @@ -2,6 +2,10 @@ 使用 [`shiki`](https://shiki.style/) 为 Markdown 代码块启用代码高亮。 +> [!WARNING] +> 相比于 官方的 [@vuepress/plugin-shiki](https://ecosystem.vuejs.press/zh/plugins/shiki.html), +> 本插件做了很多各种各样的调整,你可以认为这是试验性的。 + ## Install ```sh diff --git a/plugins/plugin-shikiji/package.json b/plugins/plugin-shikiji/package.json index eb02188c..ff148522 100644 --- a/plugins/plugin-shikiji/package.json +++ b/plugins/plugin-shikiji/package.json @@ -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", diff --git a/plugins/plugin-shikiji/src/client/composables/copy-code.ts b/plugins/plugin-shikiji/src/client/composables/copy-code.ts new file mode 100644 index 00000000..c6e9b932 --- /dev/null +++ b/plugins/plugin-shikiji/src/client/composables/copy-code.ts @@ -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>() + + 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) + }) + } + }) +} diff --git a/plugins/plugin-shikiji/src/client/composables/twoslash.ts b/plugins/plugin-shikiji/src/client/composables/twoslash.ts new file mode 100644 index 00000000..5b0ed04f --- /dev/null +++ b/plugins/plugin-shikiji/src/client/composables/twoslash.ts @@ -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, + }, + }, + }) +} diff --git a/plugins/plugin-shikiji/src/client/config.ts b/plugins/plugin-shikiji/src/client/config.ts deleted file mode 100644 index fd6c70b7..00000000 --- a/plugins/plugin-shikiji/src/client/config.ts +++ /dev/null @@ -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 diff --git a/plugins/plugin-shikiji/src/node/copy-code-button/copyCodeButtonLocales.ts b/plugins/plugin-shikiji/src/node/copy-code-button/copyCodeButtonLocales.ts new file mode 100644 index 00000000..222c4358 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/copy-code-button/copyCodeButtonLocales.ts @@ -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 + = { + '/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', + }, + } diff --git a/plugins/plugin-shikiji/src/node/copy-code-button/copyCodeButtonPlugin.ts b/plugins/plugin-shikiji/src/node/copy-code-button/copyCodeButtonPlugin.ts new file mode 100644 index 00000000..1219ff41 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/copy-code-button/copyCodeButtonPlugin.ts @@ -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('>${copyCodeButton} string) | null { + if (options === false) + return null + + const { className = 'copy', locales: userLocales = {} } + = isPlainObject(options) ? options : {} + + const root = getRootLangPath(app) + const locales: LocaleConfig = { + // 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 `` + } +} diff --git a/plugins/plugin-shikiji/src/node/copy-code-button/index.ts b/plugins/plugin-shikiji/src/node/copy-code-button/index.ts new file mode 100644 index 00000000..c072fd15 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/copy-code-button/index.ts @@ -0,0 +1,3 @@ +export * from './createCopyCodeButtonRender.js' +export * from './copyCodeButtonLocales.js' +export * from './copyCodeButtonPlugin.js' diff --git a/plugins/plugin-shikiji/src/node/highlight.ts b/plugins/plugin-shikiji/src/node/highlight.ts index 48e23454..5a42d93b 100644 --- a/plugins/plugin-shikiji/src/node/highlight.ts +++ b/plugins/plugin-shikiji/src/node/highlight.ts @@ -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(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() 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 }), diff --git a/plugins/plugin-shikiji/src/node/markdown/highlightLinesPlugin.ts b/plugins/plugin-shikiji/src/node/markdown/highlightLinesPlugin.ts new file mode 100644 index 00000000..3c30f3c0 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/markdown/highlightLinesPlugin.ts @@ -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) + } +} diff --git a/plugins/plugin-shikiji/src/node/markdown/index.ts b/plugins/plugin-shikiji/src/node/markdown/index.ts new file mode 100644 index 00000000..18c329c3 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/markdown/index.ts @@ -0,0 +1,3 @@ +export * from './highlightLinesPlugin.js' +export * from './lineNumberPlugin.js' +export * from './preWrapperPlugin.js' diff --git a/plugins/plugin-shikiji/src/node/markdown/lineNumberPlugin.ts b/plugins/plugin-shikiji/src/node/markdown/lineNumberPlugin.ts new file mode 100644 index 00000000..718d389f --- /dev/null +++ b/plugins/plugin-shikiji/src/node/markdown/lineNumberPlugin.ts @@ -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(''), + rawCode.indexOf(''), + ) + + const lines = code.split('\n') + + if ( + typeof lineNumbers === 'number' + && lines.length < lineNumbers + && !enableLineNumbers + ) + return rawCode + + const lineNumbersCode = [...Array(lines.length)] + .map(() => `
`) + .join('') + + const lineNumbersWrapperCode = `` + + const finalCode = rawCode + .replace(/<\/div>$/, `${lineNumbersWrapperCode}`) + .replace(/"(language-[^"]*?)"/, '"$1 line-numbers-mode"') + + return finalCode + } +} diff --git a/plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts b/plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts new file mode 100644 index 00000000..ecc8457c --- /dev/null +++ b/plugins/plugin-shikiji/src/node/markdown/preWrapperPlugin.ts @@ -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 `` attributes + result = result.replace(//, '') + result = `
${result}`
+  }
+}
diff --git a/plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts b/plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts
new file mode 100644
index 00000000..0ffabaa4
--- /dev/null
+++ b/plugins/plugin-shikiji/src/node/prepareClientConfigFile.ts
@@ -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 {
+  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__,
+    })
+  },`
+: ''}
+}
+`,
+  )
+}
diff --git a/plugins/plugin-shikiji/src/node/shikiPlugin.ts b/plugins/plugin-shikiji/src/node/shikiPlugin.ts
index 00eecd05..e0f28826 100644
--- a/plugins/plugin-shikiji/src/node/shikiPlugin.ts
+++ b/plugins/plugin-shikiji/src/node/shikiPlugin.ts
@@ -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(preWrapperPlugin, {
+        preWrapper,
+      })
+      if (preWrapper) {
+        copyCodeButtonPlugin(md, app, copyCode)
+        md.use(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
+        }
       }
     },
   }
diff --git a/plugins/plugin-shikiji/src/node/renderer-floating-vue.ts b/plugins/plugin-shikiji/src/node/twoslash/renderer-floating-vue.ts
similarity index 100%
rename from plugins/plugin-shikiji/src/node/renderer-floating-vue.ts
rename to plugins/plugin-shikiji/src/node/twoslash/renderer-floating-vue.ts
diff --git a/plugins/plugin-shikiji/src/node/rendererTransformer.ts b/plugins/plugin-shikiji/src/node/twoslash/rendererTransformer.ts
similarity index 95%
rename from plugins/plugin-shikiji/src/node/rendererTransformer.ts
rename to plugins/plugin-shikiji/src/node/twoslash/rendererTransformer.ts
index 4e51334e..3040ff3a 100644
--- a/plugins/plugin-shikiji/src/node/rendererTransformer.ts
+++ b/plugins/plugin-shikiji/src/node/twoslash/rendererTransformer.ts
@@ -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)
diff --git a/plugins/plugin-shikiji/src/node/types.ts b/plugins/plugin-shikiji/src/node/types.ts
index 8120c080..4950d5c2 100644
--- a/plugins/plugin-shikiji/src/node/types.ts
+++ b/plugins/plugin-shikiji/src/node/types.ts
@@ -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 `
` tag with an extra `
` 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 + * + * `` + */ +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 +} + +export interface CopyCodeLocaleOptions { + /** + * Title of the button + * + * @default 'Copy code' + */ + title?: string + + /** + * Copied text + * + * @default 'Copied!' + */ + copied?: string +} diff --git a/plugins/plugin-shikiji/src/node/utils/attrsToLines.ts b/plugins/plugin-shikiji/src/node/utils/attrsToLines.ts new file mode 100644 index 00000000..af17e68a --- /dev/null +++ b/plugins/plugin-shikiji/src/node/utils/attrsToLines.ts @@ -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'], + })) +} diff --git a/plugins/plugin-shikiji/src/node/utils/index.ts b/plugins/plugin-shikiji/src/node/utils/index.ts new file mode 100644 index 00000000..9c345151 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/utils/index.ts @@ -0,0 +1,4 @@ +export * from './attrsToLines.js' +export * from './resolveAttr.js' +export * from './resolveLanguage.js' +export * from './lru.js' diff --git a/plugins/plugin-shikiji/src/node/lru.ts b/plugins/plugin-shikiji/src/node/utils/lru.ts similarity index 100% rename from plugins/plugin-shikiji/src/node/lru.ts rename to plugins/plugin-shikiji/src/node/utils/lru.ts diff --git a/plugins/plugin-shikiji/src/node/utils/resolveAttr.ts b/plugins/plugin-shikiji/src/node/utils/resolveAttr.ts new file mode 100644 index 00000000..f787df2c --- /dev/null +++ b/plugins/plugin-shikiji/src/node/utils/resolveAttr.ts @@ -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*(?['"])(?.+?)\\k(\\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 +} diff --git a/plugins/plugin-shikiji/src/node/resolveAttrs.ts b/plugins/plugin-shikiji/src/node/utils/resolveAttrs.ts similarity index 100% rename from plugins/plugin-shikiji/src/node/resolveAttrs.ts rename to plugins/plugin-shikiji/src/node/utils/resolveAttrs.ts diff --git a/plugins/plugin-shikiji/src/node/utils/resolveLanguage.ts b/plugins/plugin-shikiji/src/node/utils/resolveLanguage.ts new file mode 100644 index 00000000..9afae671 --- /dev/null +++ b/plugins/plugin-shikiji/src/node/utils/resolveLanguage.ts @@ -0,0 +1,8 @@ +const VUE_RE = /-vue$/ + +export function resolveLanguage(info: string): string { + return info + .match(/^([^ :[{]+)/)?.[1] + ?.replace(VUE_RE, '') + .toLowerCase() ?? '' +} diff --git a/plugins/plugin-shikiji/tsconfig.build.json b/plugins/plugin-shikiji/tsconfig.build.json index 6bf67375..b1d1e44d 100644 --- a/plugins/plugin-shikiji/tsconfig.build.json +++ b/plugins/plugin-shikiji/tsconfig.build.json @@ -2,6 +2,7 @@ "extends": "../tsconfig.build.json", "compilerOptions": { "rootDir": "./src", + "types": ["vuepress/client-types"], "outDir": "./lib" }, "include": ["./src"] diff --git a/theme/src/client/styles/code.scss b/theme/src/client/styles/code.scss index 583dd30f..318e35d0 100644 --- a/theme/src/client/styles/code.scss +++ b/theme/src/client/styles/code.scss @@ -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)); +} diff --git a/theme/src/client/styles/twoslash.scss b/theme/src/client/styles/twoslash.scss index 7c46df32..2aa31e7c 100644 --- a/theme/src/client/styles/twoslash.scss +++ b/theme/src/client/styles/twoslash.scss @@ -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 * { diff --git a/theme/src/client/styles/vars.scss b/theme/src/client/styles/vars.scss index e91a6e30..c8d4e99d 100644 --- a/theme/src/client/styles/vars.scss +++ b/theme/src/client/styles/vars.scss @@ -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 * -------------------------------------------------------------------------- */ diff --git a/theme/src/node/plugins.ts b/theme/src/node/plugins.ts index a8ea7bac..d4c79aef 100644 --- a/theme/src/node/plugins.ts +++ b/theme/src/node/plugins.ts @@ -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( diff --git a/theme/src/shared/options/plugins.ts b/theme/src/shared/options/plugins.ts index 755b1985..40807dba 100644 --- a/theme/src/shared/options/plugins.ts +++ b/theme/src/shared/options/plugins.ts @@ -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