feat(plugin-md-power): add copy button for file-tree container, close #835 (#837)

* feat(plugin-md-power): add copy button for file-tree container, close #835

* chore: tweak
This commit is contained in:
pengzhanbo 2026-02-13 01:16:47 +08:00 committed by GitHub
parent b1f996cb0e
commit 2780abd782
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 284 additions and 119 deletions

View File

@ -6,17 +6,32 @@ exports[`fileTree > parseFileTreeRawContent > should work 1`] = `
"children": [
{
"children": [],
"info": "README.md",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "README.md",
"focus": false,
"level": 1,
"type": "file",
},
{
"children": [],
"info": "foo.md",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "foo.md",
"focus": false,
"level": 1,
"type": "file",
},
],
"info": "docs",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "docs",
"focus": false,
"level": 0,
"type": "file",
},
{
"children": [
@ -26,52 +41,97 @@ exports[`fileTree > parseFileTreeRawContent > should work 1`] = `
"children": [
{
"children": [],
"info": "**Navbar.vue**",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "Navbar.vue",
"focus": true,
"level": 3,
"type": "file",
},
],
"info": "components",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "components",
"focus": false,
"level": 2,
"type": "file",
},
{
"children": [],
"info": "index.ts # comment",
"comment": "# comment",
"diff": undefined,
"expanded": true,
"filename": "index.ts",
"focus": false,
"level": 2,
"type": "file",
},
],
"info": "client",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "client",
"focus": false,
"level": 1,
"type": "file",
},
{
"children": [
{
"children": [],
"info": "index.ts",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "index.ts",
"focus": false,
"level": 2,
"type": "file",
},
],
"info": "node",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "node",
"focus": false,
"level": 1,
"type": "file",
},
],
"info": "src",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "src",
"focus": false,
"level": 0,
"type": "file",
},
{
"children": [],
"info": ".gitignore",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": ".gitignore",
"focus": false,
"level": 0,
"type": "file",
},
{
"children": [],
"info": "package.json",
"comment": "",
"diff": undefined,
"expanded": true,
"filename": "package.json",
"focus": false,
"level": 0,
"type": "file",
},
]
`;
exports[`fileTreePlugin > should work with default options 1`] = `
"<div class="vp-file-tree"><FileTreeNode expanded type="folder" filename="docs" :level="0">
"<div class="vp-file-tree"><VPCopyButton text="eJzT43o0Zc6jKQ1ApJCSn1wM5DYpKCggBINcHV18XfVyU+AyU6Ayafn5EGGY0uKiZAztyTmZqXklcGE0yfzcgvw8oDzCWmQSZpFfYllSYpFeWWkqmjKYgsy8lNQKPSRTYBJ5+SkwPSCAVQfMMXrpmSWZ6Xn5RSAdMHUFicnZiempelnF+XkANX+Jpw==" encode aria-label="Copy" data-copied="Copied" /><FileTreeNode expanded type="folder" filename="docs" :level="0">
<template #icon><VPIcon provider="iconify" name="vscode-icons:folder-type-docs" /></template><FileTreeNode type="file" filename="README.md" :level="1">
<template #icon><VPIcon provider="iconify" name="flat-color-icons:info" /></template>
</FileTreeNode>
@ -102,7 +162,7 @@ exports[`fileTreePlugin > should work with default options 1`] = `
<FileTreeNode type="file" filename="package.json" :level="0">
<template #icon><VPIcon provider="iconify" name="vscode-icons:file-type-node" /></template>
</FileTreeNode></div>
<div class="vp-file-tree"><p class="vp-file-tree-title">files</p><FileTreeNode expanded type="folder" filename="src" :level="0">
<div class="vp-file-tree"><p class="vp-file-tree-title">files</p><VPCopyButton text="eJzT43o0Zc6jKQ1ApFBclAzkNSkoKCDEsooxhMpKU+FiU6BiycUgdTBekKuji6+rXm4KAKLWLVo=" encode aria-label="Copy" data-copied="Copied" /><FileTreeNode expanded type="folder" filename="src" :level="0">
<template #icon><VPIcon provider="iconify" name="vscode-icons:folder-type-src" /></template><FileTreeNode expanded type="folder" filename="js" :level="1">
<template #icon><VPIcon provider="iconify" name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="…" :level="2">
@ -122,7 +182,7 @@ exports[`fileTreePlugin > should work with default options 1`] = `
<FileTreeNode type="file" filename="README.md" :level="0">
<template #icon><VPIcon provider="iconify" name="flat-color-icons:info" /></template>
</FileTreeNode></div>
<div class="vp-file-tree"><FileTreeNode type="file" filename="docs" :level="0">
<div class="vp-file-tree"><VPCopyButton text="eJzT43o0Zc6jKQ1ApJCSn1yMxC0uSgbymhQUFBBiiXpZICUQwSlQwSS9EpAgjBvk6uji66qXmwIAUH4sGA==" encode aria-label="Copy" data-copied="Copied" /><FileTreeNode type="file" filename="docs" :level="0">
<template #icon><VPIcon provider="iconify" name="vscode-icons:default-file" /></template>
</FileTreeNode>
<FileTreeNode expanded type="folder" filename="src" :level="0">
@ -136,7 +196,7 @@ exports[`fileTreePlugin > should work with default options 1`] = `
<FileTreeNode type="file" filename="README.md" :level="0">
<template #icon><VPIcon provider="iconify" name="vscode-icons:default-file" /></template>
</FileTreeNode></div>
<div class="vp-file-tree"><FileTreeNode type="file" filename="" :level="0">
<div class="vp-file-tree"><VPCopyButton text="eJzT43o0Zc6jKQ1ApABkToExFYAAzgUAZ2oS9w==" encode aria-label="Copy" data-copied="Copied" /><FileTreeNode type="file" filename="" :level="0">
<template #icon><VPIcon provider="iconify" name="vscode-icons:default-file" /></template>
</FileTreeNode>
<FileTreeNode expanded type="folder" filename="" :level="0">
@ -144,7 +204,7 @@ exports[`fileTreePlugin > should work with default options 1`] = `
<template #icon><VPIcon provider="iconify" name="vscode-icons:default-file" /></template>
</FileTreeNode>
</FileTreeNode></div>
<div class="vp-file-tree"><FileTreeNode expanded type="folder" filename="docs" :level="0">
<div class="vp-file-tree"><VPCopyButton text="eJzT43o0Zc6jKQ1ApJCSn1wM5DYpKCggBBNTUlJT9HJT4BJToBJFqbn5ZakQGZji4qJkIA+moji/tCg5FQAkoS+X" encode aria-label="Copy" data-copied="Copied" /><FileTreeNode expanded type="folder" filename="docs" :level="0">
<template #icon><VPIcon provider="iconify" name="vscode-icons:folder-type-docs" /></template><FileTreeNode type="file" diff="add" filename="added.md" :level="1">
<template #icon><VPIcon provider="iconify" name="vscode-icons:file-type-markdown" /></template>
</FileTreeNode>
@ -158,7 +218,7 @@ exports[`fileTreePlugin > should work with default options 1`] = `
<FileTreeNode type="file" diff="remove" filename="source" :level="0">
<template #icon><VPIcon provider="iconify" name="vscode-icons:default-file" /></template>
</FileTreeNode></div>
<div class="vp-file-tree"></div>
<div class="vp-file-tree"><VPCopyButton text="eJzTAwAALwAv" encode aria-label="Copy" data-copied="Copied" /></div>
"
`;
@ -166,7 +226,7 @@ exports[`fileTreePlugin > should work with nesting content 1`] = `
"<ul>
<li>
<p>item1</p>
<div class="vp-file-tree"><FileTreeNode type="folder" filename="docs" :level="0">
<div class="vp-file-tree"><VPCopyButton text="eJzT43o0Zc6jKQ1ApJCSn1yMxC0uSgbymhQUFBBiiXpZICUQwSlQwSS9EpAgjBvk6uji66qXmwIAUH4sGA==" encode aria-label="Copy" data-copied="Copied" /><FileTreeNode type="folder" filename="docs" :level="0">
<template #icon><VPIcon provider="iconify" name="vscode-icons:folder-type-docs" /></template><FileTreeNode type="file" filename="…" :level="1">
</FileTreeNode>

View File

@ -56,7 +56,9 @@ describe('fileTree > parseFileTreeNodeInfo', () => {
})
function createMarkdown(options?: FileTreeOptions) {
return new MarkdownIt().use(fileTreePlugin, options)
const md = new MarkdownIt()
fileTreePlugin(md, options, {})
return md
}
describe('fileTreePlugin', () => {

View File

@ -68,6 +68,7 @@ function toggle(ev: MouseEvent) {
<style>
.vp-file-tree {
position: relative;
max-width: 100%;
padding: 16px;
overflow: auto hidden;
@ -87,6 +88,14 @@ function toggle(ev: MouseEvent) {
transition: color var(--vp-t-color), border-color var(--vp-t-color);
}
.vp-file-tree .vp-file-tree-title + .vp-copy-code-button {
top: calc(45px + 1em);
}
.vp-file-tree:hover .vp-copy-code-button {
opacity: 1;
}
.vp-file-tree .vp-file-tree-info {
position: relative;
display: flex;

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import { decodeData } from '@vuepress/helper/client'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
const { text, encode = false } = defineProps<{
text: string
encode?: boolean
}>()
const content = computed(() => encode ? decodeData(text) : text)
const { copied, copy } = useClipboard()
</script>
<template>
<button
type="button" class="vp-copy-code-button" :class="{ copied }"
aria-label="Copy"
data-copied="Copied"
@click="copy(content)"
/>
</template>

View File

@ -95,8 +95,6 @@ export function createContainerSyntaxPlugin(
if (state.src[pos] !== maker)
return false
pos += markerMinLen
// 检查 marker 长度是否满足要求
for (pos = start + 1; pos <= max; pos++) {
if (state.src[pos] !== maker)

View File

@ -1,6 +1,7 @@
import type { Markdown } from 'vuepress/markdown'
import type { FileTreeIconMode, FileTreeOptions } from '../../shared/index.js'
import { removeEndingSlash } from 'vuepress/shared'
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import type { CommonLocaleData, FileTreeIconMode, FileTreeOptions } from '../../shared/index.js'
import { encodeData } from '@vuepress/helper'
import { ensureLeadingSlash, removeEndingSlash, resolveLocalePath } from 'vuepress/shared'
import { defaultFile, defaultFolder, getFileIcon } from '../fileIcons/index.js'
import { stringifyAttrs } from '../utils/stringifyAttrs.js'
import { createContainerSyntaxPlugin } from './createContainer.js'
@ -8,8 +9,7 @@ import { createContainerSyntaxPlugin } from './createContainer.js'
/**
*
*/
interface FileTreeNode {
info: string
interface FileTreeNode extends FileTreeNodeProps {
level: number
children: FileTreeNode[]
}
@ -41,7 +41,7 @@ export interface FileTreeNodeProps {
* @returns
*/
export function parseFileTreeRawContent(content: string): FileTreeNode[] {
const root: FileTreeNode = { info: '', level: -1, children: [] }
const root: FileTreeNode = { level: -1, children: [] } as unknown as FileTreeNode
const stack: FileTreeNode[] = [root]
const lines = content.trimEnd().split('\n')
const spaceLength = lines[0].match(/^\s*/)?.[0].length ?? 0 // 去除行首空格/)
@ -60,7 +60,7 @@ export function parseFileTreeRawContent(content: string): FileTreeNode[] {
}
const parent = stack[stack.length - 1]
const node: FileTreeNode = { info, level, children: [] }
const node: FileTreeNode = { level, children: [], ...parseFileTreeNodeInfo(info) }
parent.children.push(node)
stack.push(node)
}
@ -124,7 +124,11 @@ export function parseFileTreeNodeInfo(info: string): FileTreeNodeProps {
* @param md markdown
* @param options
*/
export function fileTreePlugin(md: Markdown, options: FileTreeOptions = {}): void {
export function fileTreePlugin(
md: Markdown,
options: FileTreeOptions = {},
locales: Record<string, CommonLocaleData>,
): void {
/**
*
*/
@ -140,13 +144,12 @@ export function fileTreePlugin(md: Markdown, options: FileTreeOptions = {}): voi
*/
const renderFileTree = (nodes: FileTreeNode[], meta: FileTreeAttrs): string =>
nodes.map((node) => {
const { info, level, children } = node
const { filename, comment, focus, expanded, type, diff } = parseFileTreeNodeInfo(info)
const { level, children, filename, comment, focus, expanded, type, diff } = node
const isOmit = filename === '…' || filename === '...' /* fallback */
// 文件夹无子节点时补充省略号
if (children.length === 0 && type === 'folder') {
children.push({ info: '…', level: level + 1, children: [] })
children.push({ level: level + 1, children: [], filename: '…', type: 'file' } as unknown as FileTreeNode)
}
const nodeType = children.length > 0 ? 'folder' : type
@ -173,13 +176,30 @@ ${renderedIcon}${renderedComment}${children.length > 0 ? renderFileTree(children
return createContainerSyntaxPlugin(
md,
'file-tree',
(tokens, index) => {
(tokens, index, _, env: MarkdownEnv) => {
const token = tokens[index]
const nodes = parseFileTreeRawContent(token.content)
const meta = token.meta as FileTreeAttrs
const cmdText = fileTreeToCMDText(nodes).trim()
const localePath = resolveLocalePath(locales, ensureLeadingSlash(env.filePathRelative || ''))
const data = locales[localePath] ?? {}
return `<div class="vp-file-tree">${
meta.title ? `<p class="vp-file-tree-title">${meta.title}</p>` : ''
}${renderFileTree(nodes, meta)}</div>\n`
}<VPCopyButton text="${encodeData(cmdText)}" encode aria-label="${data.copy || 'Copy'}" data-copied="${data.copied || 'Copied'}" />${
renderFileTree(nodes, meta)
}</div>\n`
},
)
}
function fileTreeToCMDText(nodes: FileTreeNode[], prefix = ''): string {
let content = prefix ? '' : '.\n'
for (let i = 0, l = nodes.length; i < l; i++) {
const { filename, children } = nodes[i]
content += `${prefix + (i === l - 1 ? '└── ' : '├── ')}${filename}\n`
const child = children.filter(n => n.filename !== '…')
if (child.length)
content += fileTreeToCMDText(child, prefix + (i === l - 1 ? ' ' : '│ '))
}
return content
}

View File

@ -1,7 +1,9 @@
import type { App } from 'vuepress'
import type { Markdown } from 'vuepress/markdown'
import type { MarkdownPowerPluginOptions } from '../../shared/index.js'
import { isPlainObject } from '@vuepress/helper'
import type { MDPowerLocaleData } from '../../shared/locale.js'
import { type ExactLocaleConfig, isPlainObject } from '@vuepress/helper'
import { findLocales } from '../utils/findLocales.js'
import { alignPlugin } from './align.js'
import { cardPlugin } from './card.js'
import { chatPlugin } from './chat.js'
@ -23,6 +25,7 @@ export async function containerPlugin(
app: App,
md: Markdown,
options: MarkdownPowerPluginOptions,
locales: ExactLocaleConfig<MDPowerLocaleData>,
): Promise<void> {
// ::: left / right / center / justify
alignPlugin(md)
@ -56,7 +59,7 @@ export async function containerPlugin(
if (options.fileTree) {
// ::: file-tree
fileTreePlugin(md, isPlainObject(options.fileTree) ? options.fileTree : {})
fileTreePlugin(md, isPlainObject(options.fileTree) ? options.fileTree : {}, findLocales(locales, 'common'))
}
if (options.codeTree) {

View File

@ -1,6 +1,10 @@
import type { MDPowerLocaleData } from '../../shared/locale'
export const deLocale: MDPowerLocaleData = {
common: {
copy: 'Kopieren',
copied: 'Kopiert',
},
encrypt: {
hint: 'Der Inhalt ist verschlüsselt, bitte entsperren Sie ihn, um ihn anzuzeigen.',
placeholder: 'Passwort eingeben',

View File

@ -1,6 +1,10 @@
import type { MDPowerLocaleData } from '../../shared/locale'
export const enLocale: MDPowerLocaleData = {
common: {
copy: 'Copy',
copied: 'Copied',
},
encrypt: {
hint: 'The content is encrypted, please unlock to view.',
placeholder: 'Enter password',

View File

@ -1,6 +1,10 @@
import type { MDPowerLocaleData } from '../../shared/locale'
export const frLocale: MDPowerLocaleData = {
common: {
copy: 'Copier',
copied: 'Copié',
},
encrypt: {
hint: 'Le contenu est chiffré, veuillez déverrouiller pour afficher.',
placeholder: 'Entrez le mot de passe',

View File

@ -1,6 +1,10 @@
import type { MDPowerLocaleData } from '../../shared/locale'
export const jaLocale: MDPowerLocaleData = {
common: {
copy: 'コピー',
copied: 'コピー済み',
},
encrypt: {
hint: 'コンテンツは暗号化されています。閲覧するにはロックを解除してください。',
placeholder: 'パスワードを入力',

View File

@ -1,6 +1,10 @@
import type { MDPowerLocaleData } from '../../shared/locale'
export const koLocale: MDPowerLocaleData = {
common: {
copy: '복사',
copied: '복사됨',
},
encrypt: {
hint: '내용이 암호화되어 있습니다. 잠금 해제 후 확인하세요.',
placeholder: '비밀번호 입력',

View File

@ -1,6 +1,10 @@
import type { MDPowerLocaleData } from '../../shared/locale'
export const ruLocale: MDPowerLocaleData = {
common: {
copy: 'Копировать',
copied: 'Скопировано',
},
encrypt: {
hint: 'Контент зашифрован, разблокируйте для просмотра.',
placeholder: 'Введите пароль',

View File

@ -1,6 +1,10 @@
import type { MDPowerLocaleData } from '../../shared/locale'
export const zhTWLocale: MDPowerLocaleData = {
common: {
copy: '複製',
copied: '已複製',
},
encrypt: {
hint: '內容已加密,請解鎖後查看。',
placeholder: '輸入密碼',

View File

@ -1,6 +1,10 @@
import type { MDPowerLocaleData } from '../../shared/locale'
export const zhLocale: MDPowerLocaleData = {
common: {
copy: '复制',
copied: '已复制',
},
encrypt: {
hint: '内容已加密,请解锁后查看。',
placeholder: '输入密码',

View File

@ -2,6 +2,7 @@ import type { Plugin } from 'vuepress/core'
import type { MarkdownPowerPluginOptions } from '../shared/index.js'
import { isPlainObject } from '@pengzhanbo/utils'
import { addViteOptimizeDepsInclude } from '@vuepress/helper'
import { getFullLocaleConfig } from '@vuepress/helper'
import { extendsPageWithCodeTree } from './container/codeTree.js'
import { containerPlugin } from './container/index.js'
import { demoPlugin, demoWatcher, extendsPageWithDemo, waitDemoRender } from './demo/index.js'
@ -11,18 +12,27 @@ import { imageSizePlugin } from './enhance/imageSize.js'
import { linksPlugin } from './enhance/links.js'
import { iconPlugin } from './icon/index.js'
import { inlineSyntaxPlugin } from './inline/index.js'
import { LOCALE_OPTIONS } from './locales/index.js'
import { prepareConfigFile } from './prepareConfigFile.js'
import { provideData } from './provideData.js'
export function markdownPowerPlugin(
options: MarkdownPowerPluginOptions = {},
): Plugin {
return (app) => {
const locales = getFullLocaleConfig({
app,
name: 'vuepress-plugin-md-power',
default: LOCALE_OPTIONS,
config: options.locales,
})
return {
name: 'vuepress-plugin-md-power',
clientConfigFile: app => prepareConfigFile(app, options),
define: app => provideData(app, options),
define: provideData(options, locales),
alias: (_, isServer) => {
if (!isServer) {
@ -64,7 +74,7 @@ export function markdownPowerPlugin(
if (options.demo)
demoPlugin(app, md)
await containerPlugin(app, md, options)
await containerPlugin(app, md, options, locales)
await imageSizePlugin(app, md, options.imageSize)
},
@ -88,3 +98,4 @@ export function markdownPowerPlugin(
},
}
}
}

View File

@ -15,6 +15,9 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
const imports = new Set<string>()
const enhances = new Set<string>()
imports.add(`import VPCopyButton from '${CLIENT_FOLDER}components/VPCopyButton.vue'`)
enhances.add(`app.component('VPCopyButton', VPCopyButton)`)
imports.add(`import Tabs from '${CLIENT_FOLDER}components/Tabs.vue'`)
enhances.add(`app.component('Tabs', Tabs)`)

View File

@ -1,22 +1,17 @@
import type { App, LocaleConfig } from 'vuepress'
import type { MarkdownPowerPluginOptions } from '../shared/index.js'
import type { MDPowerLocaleData } from '../shared/locale.js'
import { getFullLocaleConfig } from '@vuepress/helper'
import type { ExactLocaleConfig } from '@vuepress/helper'
import type { MarkdownPowerPluginOptions, MDPowerLocaleData } from '../shared/index.js'
import { isPackageExists } from 'local-pkg'
import { LOCALE_OPTIONS } from './locales/index.js'
import { findLocales } from './utils/findLocales.js'
export function provideData(app: App, options: MarkdownPowerPluginOptions): Record<string, unknown> {
export function provideData(
options: MarkdownPowerPluginOptions,
locales: ExactLocaleConfig<MDPowerLocaleData>,
): Record<string, unknown> {
const markdownOptions = {
plot: options.plot,
pdf: options.pdf,
}
const locales = getFullLocaleConfig({
app,
name: 'vuepress-plugin-md-power',
default: LOCALE_OPTIONS,
config: options.locales,
})
const icon = options.icon ?? { provider: 'iconify' }
return {
@ -29,16 +24,3 @@ export function provideData(app: App, options: MarkdownPowerPluginOptions): Reco
__MD_POWER_ENCRYPT_LOCALES__: options.encrypt ? findLocales(locales, 'encrypt') : {},
}
}
function findLocales<
T extends MDPowerLocaleData,
K extends keyof T,
>(locales: LocaleConfig<T>, key: K): Record<string, T[K]> {
const res: Record<string, T[K]> = {}
for (const [locale, value] of Object.entries(locales)) {
res[locale] = value[key] ?? {} as T[K]
}
return res
}

View File

@ -0,0 +1,15 @@
import type { LocaleConfig } from 'vuepress'
import type { MDPowerLocaleData } from '../../shared/index.js'
export function findLocales<
T extends MDPowerLocaleData,
K extends keyof T,
>(locales: LocaleConfig<T>, key: K): Record<string, T[K]> {
const res: Record<string, T[K]> = {}
for (const [locale, value] of Object.entries(locales)) {
res[locale] = value[key] ?? {} as T[K]
}
return res
}

View File

@ -7,6 +7,7 @@ export * from './env.js'
export * from './fileTree.js'
export * from './icon.js'
export * from './jsfiddle.js'
export * from './locale.js'
export * from './npmTo.js'
export * from './pdf.js'
export * from './plot.js'

View File

@ -2,5 +2,11 @@ import type { LocaleData } from 'vuepress'
import type { EncryptSnippetLocale } from './encrypt'
export interface MDPowerLocaleData extends LocaleData {
common?: CommonLocaleData
encrypt?: EncryptSnippetLocale
}
export interface CommonLocaleData extends LocaleData {
copy?: string
copied?: string
}