240 lines
7.2 KiB
TypeScript
240 lines
7.2 KiB
TypeScript
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'
|
|
|
|
/**
|
|
* File tree node structure
|
|
*
|
|
* 文件树节点结构
|
|
*/
|
|
interface FileTreeNode extends FileTreeNodeProps {
|
|
level: number
|
|
children: FileTreeNode[]
|
|
}
|
|
|
|
/**
|
|
* File tree container attributes
|
|
*
|
|
* 文件树容器属性
|
|
*/
|
|
interface FileTreeAttrs {
|
|
title?: string
|
|
icon?: FileTreeIconMode
|
|
}
|
|
|
|
/**
|
|
* File tree node props (for rendering component)
|
|
*
|
|
* 文件树节点属性(用于渲染组件)
|
|
*/
|
|
export interface FileTreeNodeProps {
|
|
filename: string
|
|
comment?: string
|
|
focus?: boolean
|
|
expanded?: boolean
|
|
type: 'folder' | 'file'
|
|
diff?: 'add' | 'remove'
|
|
level?: number
|
|
}
|
|
|
|
/**
|
|
* Parse raw file tree content to node tree structure
|
|
*
|
|
* 解析原始文件树内容为节点树结构
|
|
*
|
|
* @param content - Raw file tree text content / 文件树的原始文本内容
|
|
* @returns File tree node array / 文件树节点数组
|
|
*/
|
|
export function parseFileTreeRawContent(content: string): FileTreeNode[] {
|
|
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 // Remove leading spaces
|
|
|
|
for (const line of lines) {
|
|
const match = line.match(/^(\s*)-(.*)$/)
|
|
if (!match)
|
|
continue
|
|
|
|
const level = Math.floor((match[1].length - spaceLength) / 2) // Two spaces per level
|
|
const info = match[2].trim()
|
|
|
|
// Find parent node at current level
|
|
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
|
|
stack.pop()
|
|
}
|
|
|
|
const parent = stack[stack.length - 1]
|
|
const node: FileTreeNode = { level, children: [], ...parseFileTreeNodeInfo(info) }
|
|
parent.children.push(node)
|
|
stack.push(node)
|
|
}
|
|
|
|
return root.children
|
|
}
|
|
|
|
/**
|
|
* Regex for focus marker
|
|
*
|
|
* 高亮标记正则
|
|
*/
|
|
const RE_FOCUS = /^\*\*(.*)\*\*(?:$|\s+)/
|
|
|
|
/**
|
|
* Parse single node info string, extract filename, comment, type, etc.
|
|
*
|
|
* 解析单个节点的 info 字符串,提取文件名、注释、类型等属性
|
|
*
|
|
* @param info - Node description string / 节点描述字符串
|
|
* @returns File tree node props / 文件树节点属性
|
|
*/
|
|
export function parseFileTreeNodeInfo(info: string): FileTreeNodeProps {
|
|
let filename = ''
|
|
let comment = ''
|
|
let focus = false
|
|
let expanded: boolean | undefined = true
|
|
let type: 'folder' | 'file' = 'file'
|
|
let diff: 'add' | 'remove' | undefined
|
|
|
|
// Process diff marker
|
|
if (info.startsWith('++')) {
|
|
info = info.slice(2).trim()
|
|
diff = 'add'
|
|
}
|
|
else if (info.startsWith('--')) {
|
|
info = info.slice(2).trim()
|
|
diff = 'remove'
|
|
}
|
|
|
|
// Process focus marker
|
|
info = info.replace(RE_FOCUS, (_, matched) => {
|
|
filename = matched
|
|
focus = true
|
|
return ''
|
|
})
|
|
|
|
// Extract filename and comment
|
|
if (filename === '' && !focus) {
|
|
const sharpIndex = info.indexOf('#')
|
|
filename = info.slice(0, sharpIndex === -1 ? info.length : sharpIndex).trim()
|
|
info = sharpIndex === -1 ? '' : info.slice(sharpIndex)
|
|
}
|
|
|
|
comment = info.trim()
|
|
|
|
// Determine if folder
|
|
if (filename.endsWith('/')) {
|
|
type = 'folder'
|
|
expanded = false
|
|
filename = removeEndingSlash(filename)
|
|
}
|
|
|
|
return { filename, comment, focus, expanded, type, diff }
|
|
}
|
|
|
|
/**
|
|
* File tree markdown plugin main function
|
|
*
|
|
* 文件树 markdown 插件主函数
|
|
*
|
|
* @param md - Markdown instance / markdown 实例
|
|
* @param options - File tree render options / 文件树渲染选项
|
|
* @param locales - Locale data / 本地化数据
|
|
*/
|
|
export function fileTreePlugin(
|
|
md: Markdown,
|
|
options: FileTreeOptions = {},
|
|
locales: Record<string, CommonLocaleData>,
|
|
): void {
|
|
/**
|
|
* Get file or folder icon
|
|
*
|
|
* 获取文件或文件夹的图标
|
|
*/
|
|
const getIcon = (filename: string, type: 'folder' | 'file', mode?: FileTreeIconMode): string => {
|
|
mode ||= options.icon || 'colored'
|
|
if (mode === 'simple')
|
|
return type === 'folder' ? defaultFolder : defaultFile
|
|
return getFileIcon(filename, type)
|
|
}
|
|
|
|
/**
|
|
* Recursively render file tree nodes
|
|
*
|
|
* 递归渲染文件树节点
|
|
*/
|
|
const renderFileTree = (nodes: FileTreeNode[], meta: FileTreeAttrs): string =>
|
|
nodes.map((node) => {
|
|
const { level, children, filename, comment, focus, expanded, type, diff } = node
|
|
const isOmit = filename === '…' || filename === '...' /* fallback */
|
|
|
|
// Add ellipsis for folder without children
|
|
if (children.length === 0 && type === 'folder') {
|
|
children.push({ level: level + 1, children: [], filename: '…', type: 'file' } as unknown as FileTreeNode)
|
|
}
|
|
|
|
const nodeType = children.length > 0 ? 'folder' : type
|
|
const renderedComment = comment
|
|
? `<template #comment>${md.renderInline(comment.replaceAll('#', '\#'))}</template>`
|
|
: ''
|
|
const renderedIcon = !isOmit
|
|
? `<template #icon><VPIcon provider="iconify" name="${getIcon(filename, nodeType, meta.icon)}" /></template>`
|
|
: ''
|
|
const props: FileTreeNodeProps = {
|
|
expanded: nodeType === 'folder' ? expanded : false,
|
|
focus,
|
|
type: nodeType,
|
|
diff,
|
|
filename,
|
|
level,
|
|
}
|
|
return `<FileTreeNode${stringifyAttrs(props, false, ['filename'])}>
|
|
${renderedIcon}${renderedComment}${children.length > 0 ? renderFileTree(children, meta) : ''}
|
|
</FileTreeNode>`
|
|
}).join('\n')
|
|
|
|
// Register custom container syntax plugin
|
|
return createContainerSyntaxPlugin(
|
|
md,
|
|
'file-tree',
|
|
(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>` : ''
|
|
}<VPCopyButton text="${encodeData(cmdText)}" encode aria-label="${data.copy || 'Copy'}" data-copied="${data.copied || 'Copied'}" />${
|
|
renderFileTree(nodes, meta)
|
|
}</div>\n`
|
|
},
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Convert file tree to command line text format
|
|
*
|
|
* 将文件树转换为命令行文本格式
|
|
*
|
|
* @param nodes - File tree nodes / 文件树节点
|
|
* @param prefix - Line prefix / 行前缀
|
|
* @returns CMD text / CMD 文本
|
|
*/
|
|
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
|
|
}
|