From bf2715ed67f290726fc6d4c85c203ca8f74cc907 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Wed, 12 Nov 2025 12:49:44 +0800 Subject: [PATCH] feat: prevent `$` symbol selection in shell code (#5025) --- src/client/app/composables/copyCode.ts | 9 ++++--- src/node/markdown/plugins/highlight.ts | 36 ++++++++++++++++++++++++++ src/shared/shared.ts | 5 ++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/client/app/composables/copyCode.ts b/src/client/app/composables/copyCode.ts index 4d35e012..3d920a85 100644 --- a/src/client/app/composables/copyCode.ts +++ b/src/client/app/composables/copyCode.ts @@ -1,6 +1,6 @@ import { inBrowser } from 'vitepress' +import { isShell } from '../../shared' -const shellRE = /language-(shellscript|shell|bash|sh|zsh)/ const ignoredNodes = ['.vp-copy-ignore', '.diff.remove'].join(', ') export function useCopyCode() { @@ -15,8 +15,6 @@ export function useCopyCode() { return } - const isShell = shellRE.test(parent.className) - // Clone the node and remove the ignored nodes const clone = sibling.cloneNode(true) as HTMLElement clone.querySelectorAll(ignoredNodes).forEach((node) => node.remove()) @@ -26,7 +24,10 @@ export function useCopyCode() { let text = clone.textContent || '' - if (isShell) { + // NOTE: Any changes to this the code here may also need to update + // `transformerDisableShellSymbolSelect` in `src/node/markdown/plugins/highlight.ts` + const lang = /language-(\w+)/.exec(parent.className)?.[1] || '' + if (isShell(lang)) { text = text.replace(/^ *(\$|>) /gm, '').trim() } diff --git a/src/node/markdown/plugins/highlight.ts b/src/node/markdown/plugins/highlight.ts index 8f427823..5862aff1 100644 --- a/src/node/markdown/plugins/highlight.ts +++ b/src/node/markdown/plugins/highlight.ts @@ -11,6 +11,7 @@ import c from 'picocolors' import type { BundledLanguage, ShikiTransformer } from 'shiki' import { createHighlighter, guessEmbeddedLanguages, isSpecialLang } from 'shiki' import type { Logger } from 'vite' +import { isShell } from '../../shared' import type { MarkdownOptions, ThemeOptions } from '../markdown' const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10) @@ -47,6 +48,40 @@ function attrsToLines(attrs: string): TransformerCompactLineOption[] { })) } +/** + * Prevents the leading '$' symbol etc from being selectable/copyable. Also + * normalizes its syntax so there's no leading spaces, and only a single + * trailing space. + * + * NOTE: Any changes to this function may also need to update + * `src/client/app/composables/copyCode.ts` + */ +function transformerDisableShellSymbolSelect(): ShikiTransformer { + return { + name: 'vitepress:disable-shell-symbol-select', + tokens(tokensByLine) { + if (!isShell(this.options.lang)) return + + for (const tokens of tokensByLine) { + if (tokens.length < 2) continue + + // The first token should only be a symbol token + const firstTokenText = tokens[0].content.trim() + if (firstTokenText !== '$' && firstTokenText !== '>') continue + + // The second token must have a leading space (separates the symbol) + if (tokens[1].content[0] !== ' ') continue + + tokens[0].content = firstTokenText + ' ' + tokens[0].htmlStyle ??= {} + tokens[0].htmlStyle['user-select'] = 'none' + tokens[0].htmlStyle['-webkit-user-select'] = 'none' + tokens[1].content = tokens[1].content.slice(1) + } + } + } +} + export async function highlight( theme: ThemeOptions, options: MarkdownOptions, @@ -83,6 +118,7 @@ export async function highlight( }), transformerNotationHighlight(), transformerNotationErrorLevel(), + transformerDisableShellSymbolSelect(), { name: 'vitepress:add-dir', pre(node) { diff --git a/src/shared/shared.ts b/src/shared/shared.ts index c556e723..f16a25ba 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -353,3 +353,8 @@ type ObjectType = Record export function isObject(value: unknown): value is ObjectType { return Object.prototype.toString.call(value) === '[object Object]' } + +const shellLangs = ['shellscript', 'shell', 'bash', 'sh', 'zsh'] +export function isShell(lang: string): boolean { + return shellLangs.includes(lang) +}