/**
* @module CodeTree
*
* code-tree 容器
* ````md
* ::: code-tree title="Project Name" height="400px" entry="filepath"
* ``` lang :active title="filepath"
* ```
*
* :::
* ````
*
* embed syntax
*
* `@[code-tree title="Project Name" height="400px" entry="filepath"](dir_path)`
*/
import type { App, Page } from 'vuepress/core'
import type { Markdown } from 'vuepress/markdown'
import type { CodeTreeOptions } from '../../shared/codeTree.js'
import type { FileTreeIconMode } from '../../shared/fileTree.js'
import type { FileTreeNodeProps } from './fileTree.js'
import path from 'node:path'
import { globSync } from 'tinyglobby'
import { removeLeadingSlash } from 'vuepress/shared'
import { findFile, readFileSync } from '../demo/supports/file.js'
import { createEmbedRuleBlock } from '../embed/createEmbedRuleBlock.js'
import { defaultFile, defaultFolder, getFileIcon } from '../fileIcons/index.js'
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
import { parseRect } from '../utils/parseRect.js'
import { resolveAttr, resolveAttrs } from '../utils/resolveAttrs.js'
import { stringifyAttrs } from '../utils/stringifyAttrs.js'
import { createContainerPlugin } from './createContainer.js'
const UNSUPPORTED_FILE_TYPES = [
/* image */
'jpg',
'jpeg',
'png',
'gif',
'avif',
'webp',
/* media */
'mp3',
'mp4',
'ogg',
'm3u8',
'm3u',
'flv',
'webm',
'wav',
'flac',
'aac',
/* document */
'pdf',
'doc',
'docx',
'ppt',
'pptx',
'xls',
'xlsx',
]
/**
* code-tree 容器元信息
*/
interface CodeTreeMeta {
title?: string
/**
* 文件图标类型
*/
icon?: FileTreeIconMode
/**
* 代码树容器高度
*/
height?: string
/**
* 入口文件,默认打开
*/
entry?: string
}
/**
* 文件树节点类型
*/
interface FileTreeNode {
level: number
children?: FileTreeNode[]
filename: string
filepath?: string
}
/**
* 将文件路径数组解析为文件树节点结构
* @param files 文件路径数组
* @returns 文件树节点数组
*/
function parseFileNodes(files: string[]): FileTreeNode[] {
const nodes: FileTreeNode[] = []
for (const file of files) {
const parts = removeLeadingSlash(file).split('/')
let node = nodes
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const isFile = i === parts.length - 1
let child = node.find(n => n.filename === part)
if (!child) {
child = {
level: i + 1,
filename: part,
filepath: isFile ? file : undefined,
children: isFile ? undefined : [],
}
node.push(child)
}
if (!isFile && child.children)
node = child.children
}
}
return nodes
}
/**
* 注册 code-tree 容器和嵌入语法的 markdown 插件
* @param md markdown-it 实例
* @param app vuepress app 实例
* @param options code-tree 配置项
*/
export function codeTreePlugin(md: Markdown, app: App, options: CodeTreeOptions = {}): void {
/**
* 获取文件或文件夹的图标
*/
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)
}
/**
* 渲染文件树节点为组件字符串
*/
function renderFileTree(nodes: FileTreeNode[], mode?: FileTreeIconMode): string {
return nodes.map((node) => {
const props: FileTreeNodeProps & { filepath?: string } = {
filename: node.filename,
level: node.level,
type: node.children?.length ? 'folder' : 'file',
expanded: true,
filepath: node.filepath,
}
return `
${node.children?.length ? renderFileTree(node.children, mode) : ''}
`
})
.join('\n')
}
// 注册 ::: code-tree 容器
createContainerPlugin(md, 'code-tree', {
before: (info, tokens, index) => {
// 收集 code-tree 容器内的文件名和激活文件
const files: string[] = []
let activeFile: string | undefined
for (
let i = index + 1;
!(
tokens[i].nesting === -1
&& tokens[i].type === 'container_code-tree_close'
);
i++
) {
const token = tokens[i]
if (token.type === 'fence' && token.tag === 'code') {
const fenceInfo = md.utils.unescapeAll(token.info)
const title = resolveAttr(fenceInfo, 'title')
if (title) {
files.push(title)
if (fenceInfo.includes(':active'))
activeFile = title
}
}
}
const { attrs } = resolveAttrs(info)
const { title, icon, height, entry } = attrs
const fileTreeNodes = parseFileNodes(files)
const entryFile = activeFile || entry || files[0]
const h = height || String(options.height)
return `${
renderFileTree(fileTreeNodes, icon)
}`
},
after: () => '',
})
// 注册 @[code-tree](dir) 语法
createEmbedRuleBlock(md, {
type: 'code-tree',
syntaxPattern: /^@\[code-tree([^\]]*)\]\(([^)]*)\)/,
meta: ([, info, dir]) => {
const { attrs } = resolveAttrs(info)
const h = attrs.height || String(options.height)
return {
title: attrs.title,
entryFile: attrs.entry,
icon: attrs.icon,
height: h ? parseRect(h) : undefined,
dir,
}
},
content: ({ dir, icon, ...props }, _, env) => {
// codeTreeFiles 用于页面依赖收集
const codeTreeFiles = ((env as any).codeTreeFiles ??= []) as string[]
const root = findFile(app, env, dir)
// 获取目录下所有文件
const files = globSync('**/*', {
cwd: root,
onlyFiles: true,
dot: true,
ignore: ['**/node_modules/**', '**/.DS_Store', '**/.gitkeep'],
}).sort((a, b) => {
const al = a.split('/').length
const bl = b.split('/').length
return bl - al
})
props.entryFile ||= files[0]
// 生成所有文件的代码块内容
const codeContent = files.map((file) => {
const ext = path.extname(file).slice(1)
if (UNSUPPORTED_FILE_TYPES.includes(ext)) {
return ''
}
const filepath = path.join(root, file)
codeTreeFiles.push(filepath)
const content = readFileSync(filepath)
return `\`\`\`${ext || 'txt'} title="${file}"\n${content}\n\`\`\``
}).filter(Boolean).join('\n')
const fileTreeNodes = parseFileNodes(files)
return `${
renderFileTree(fileTreeNodes, icon)
}${md.render(codeContent, cleanMarkdownEnv(env))}`
},
})
}
/**
* 扩展页面依赖,将 codeTreeFiles 添加到页面依赖中
* @param page vuepress 页面对象
*/
export function extendsPageWithCodeTree(page: Page): void {
const markdownEnv = page.markdownEnv
const codeTreeFiles = (markdownEnv.codeTreeFiles ?? []) as string[]
if (codeTreeFiles.length)
page.deps.push(...codeTreeFiles)
}