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, ): 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 ? `` : '' const renderedIcon = !isOmit ? `` : '' const props: FileTreeNodeProps = { expanded: nodeType === 'folder' ? expanded : false, focus, type: nodeType, diff, filename, level, } return ` ${renderedIcon}${renderedComment}${children.length > 0 ? renderFileTree(children, meta) : ''} ` }).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 `
${ meta.title ? `

${meta.title}

` : '' }${ renderFileTree(nodes, meta) }
\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 }