diff --git a/plugins/plugin-md-power/__test__/__snapshots__/fileTreePlugin.spec.ts.snap b/plugins/plugin-md-power/__test__/__snapshots__/fileTreePlugin.spec.ts.snap
index b39ac60a..272b81d5 100644
--- a/plugins/plugin-md-power/__test__/__snapshots__/fileTreePlugin.spec.ts.snap
+++ b/plugins/plugin-md-power/__test__/__snapshots__/fileTreePlugin.spec.ts.snap
@@ -1,66 +1,149 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`fileTreePlugin > should work with default options 1`] = `
-"
-docs
-
-
-src
-
-client
-
-components
-
-
-index.ts
-
-
-node
-
-
-
-
-.gitignore
-package.json
-
-
-docs
-src
-
-
-README.md
-
-
"
+exports[`fileTree > parseFileTreeRawContent > should work 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "info": "README.md",
+ "level": 1,
+ },
+ {
+ "children": [],
+ "info": "foo.md",
+ "level": 1,
+ },
+ ],
+ "info": "docs",
+ "level": 0,
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "info": "**Navbar.vue**",
+ "level": 3,
+ },
+ ],
+ "info": "components",
+ "level": 2,
+ },
+ {
+ "children": [],
+ "info": "index.ts # comment",
+ "level": 2,
+ },
+ ],
+ "info": "client",
+ "level": 1,
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "info": "index.ts",
+ "level": 2,
+ },
+ ],
+ "info": "node",
+ "level": 1,
+ },
+ ],
+ "info": "src",
+ "level": 0,
+ },
+ {
+ "children": [],
+ "info": ".gitignore",
+ "level": 0,
+ },
+ {
+ "children": [],
+ "info": "package.json",
+ "level": 0,
+ },
+]
+`;
+
+exports[`fileTreePlugin > should work with default options 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+# comment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+files
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"
`;
diff --git a/plugins/plugin-md-power/__test__/fileTreePlugin.spec.ts b/plugins/plugin-md-power/__test__/fileTreePlugin.spec.ts
index 7ba6a00c..9084c4dc 100644
--- a/plugins/plugin-md-power/__test__/fileTreePlugin.spec.ts
+++ b/plugins/plugin-md-power/__test__/fileTreePlugin.spec.ts
@@ -1,7 +1,59 @@
import type { FileTreeOptions } from '../src/shared/fileTree.js'
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
-import { fileTreePlugin } from '../src/node/container/fileTree.js'
+import { fileTreePlugin, parseFileTreeNodeInfo, parseFileTreeRawContent } from '../src/node/container/fileTree.js'
+
+describe('fileTree > parseFileTreeRawContent', () => {
+ it('should work', () => {
+ const content = `\
+- docs
+ - README.md
+ - foo.md
+
+- src
+ - client
+ - components
+ - **Navbar.vue**
+ - index.ts # comment
+ - node
+ - index.ts
+- .gitignore
+- package.json
+`
+ const nodes = parseFileTreeRawContent(content)
+ expect(nodes).toMatchSnapshot()
+ })
+})
+
+describe('fileTree > parseFileTreeNodeInfo', () => {
+ it('should work', () => {
+ expect(parseFileTreeNodeInfo('README.md'))
+ .toEqual({ filename: 'README.md', comment: '', focus: false, expanded: true, type: 'file' })
+
+ expect(parseFileTreeNodeInfo('README.md # comment'))
+ .toEqual({ filename: 'README.md', comment: '# comment', focus: false, expanded: true, type: 'file' })
+
+ expect(parseFileTreeNodeInfo('**Navbar.vue**'))
+ .toEqual({ filename: 'Navbar.vue', comment: '', focus: true, expanded: true, type: 'file' })
+
+ expect(parseFileTreeNodeInfo('**Navbar.vue** # comment'))
+ .toEqual({ filename: 'Navbar.vue', comment: '# comment', focus: true, expanded: true, type: 'file' })
+ })
+
+ it('should work with expanded', () => {
+ expect(parseFileTreeNodeInfo('folder/'))
+ .toEqual({ filename: 'folder', comment: '', focus: false, expanded: false, type: 'folder' })
+
+ expect(parseFileTreeNodeInfo('folder/ # comment'))
+ .toEqual({ filename: 'folder', comment: '# comment', focus: false, expanded: false, type: 'folder' })
+
+ expect(parseFileTreeNodeInfo('**folder/**'))
+ .toEqual({ filename: 'folder', comment: '', focus: true, expanded: false, type: 'folder' })
+
+ expect(parseFileTreeNodeInfo('**folder/** # comment'))
+ .toEqual({ filename: 'folder', comment: '# comment', focus: true, expanded: false, type: 'folder' })
+ })
+})
function createMarkdown(options?: FileTreeOptions) {
return new MarkdownIt().use(fileTreePlugin, options)
diff --git a/plugins/plugin-md-power/src/client/components/FileTreeItem.vue b/plugins/plugin-md-power/src/client/components/FileTreeItem.vue
deleted file mode 100644
index 2cc56677..00000000
--- a/plugins/plugin-md-power/src/client/components/FileTreeItem.vue
+++ /dev/null
@@ -1,183 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/plugins/plugin-md-power/src/client/components/FileTreeNode.vue b/plugins/plugin-md-power/src/client/components/FileTreeNode.vue
new file mode 100644
index 00000000..adf7f832
--- /dev/null
+++ b/plugins/plugin-md-power/src/client/components/FileTreeNode.vue
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+ {{ filename }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/plugin-md-power/src/node/container/fileTree.ts b/plugins/plugin-md-power/src/node/container/fileTree.ts
index 55ccf150..2dc6743a 100644
--- a/plugins/plugin-md-power/src/node/container/fileTree.ts
+++ b/plugins/plugin-md-power/src/node/container/fileTree.ts
@@ -1,17 +1,14 @@
import type { Markdown } from 'vuepress/markdown'
import type { FileTreeIconMode, FileTreeOptions } from '../../shared/index.js'
-import container from 'markdown-it-container'
-import Token from 'markdown-it/lib/token.mjs'
-import { removeEndingSlash, removeLeadingSlash } from 'vuepress/shared'
+import { removeEndingSlash } from 'vuepress/shared'
import { defaultFile, defaultFolder, getFileIcon } from '../fileIcons/index.js'
-import { resolveAttrs } from '../utils/resolveAttrs.js'
+import { stringifyAttrs } from '../utils/stringifyAttrs.js'
+import { createContainerSyntaxPlugin } from './createContainer.js'
interface FileTreeNode {
- filename: string
- type: 'folder' | 'file'
- expanded: boolean
- focus: boolean
- empty: boolean
+ info: string
+ level: number
+ children: FileTreeNode[]
}
interface FileTreeAttrs {
@@ -19,11 +16,63 @@ interface FileTreeAttrs {
icon?: FileTreeIconMode
}
-const type = 'file-tree'
-const closeType = `container_${type}_close`
-const componentName = 'FileTreeItem'
-const itemOpen = 'file_tree_item_open'
-const itemClose = 'file_tree_item_close'
+export function parseFileTreeRawContent(content: string): FileTreeNode[] {
+ const root: FileTreeNode = { info: '', level: -1, children: [] }
+ const stack: FileTreeNode[] = [root]
+ const lines = content.trim().split('\n')
+ for (const line of lines) {
+ const match = line.match(/^(\s*)-(.*)$/)
+ if (!match)
+ continue
+
+ const level = Math.floor(match[1].length / 2) // 每两个空格为一个层级
+ const info = match[2].trim()
+
+ // 检索当前层级的父节点
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
+ stack.pop()
+ }
+
+ const parent = stack[stack.length - 1]
+ const node: FileTreeNode = { info, level, children: [] }
+ parent.children.push(node)
+ stack.push(node)
+ }
+
+ return root.children
+}
+
+const RE_FOCUS = /^\*\*(.*)\*\*(?:$|\s+)/
+
+export function parseFileTreeNodeInfo(info: string) {
+ let filename = ''
+ let comment = ''
+ let focus = false
+ let expanded: boolean | undefined = true
+ let type: 'folder' | 'file' = 'file'
+
+ info = info.replace(RE_FOCUS, (_, matched) => {
+ filename = matched
+ focus = true
+ return ''
+ })
+
+ if (filename === '' && !focus) {
+ const spaceIndex = info.indexOf(' ')
+ filename = info.slice(0, spaceIndex === -1 ? info.length : spaceIndex)
+ info = spaceIndex === -1 ? '' : info.slice(spaceIndex)
+ }
+
+ comment = info.trim()
+
+ if (filename.endsWith('/')) {
+ type = 'folder'
+ expanded = false
+ filename = removeEndingSlash(filename)
+ }
+
+ return { filename, comment, focus, expanded, type }
+}
export function fileTreePlugin(md: Markdown, options: FileTreeOptions = {}) {
const getIcon = (filename: string, type: 'folder' | 'file', mode?: FileTreeIconMode): string => {
@@ -33,166 +82,44 @@ export function fileTreePlugin(md: Markdown, options: FileTreeOptions = {}) {
return getFileIcon(filename, type)
}
- const render = (tokens: Token[], idx: number): string => {
- const { attrs } = resolveAttrs(tokens[idx].info.slice(type.length - 1))
+ const renderFileTree = (nodes: FileTreeNode[], meta: FileTreeAttrs): string =>
+ nodes.map((node) => {
+ const { info, level, children } = node
+ const { filename, comment, focus, expanded, type } = parseFileTreeNodeInfo(info)
+ const isOmit = filename === '…' || filename === '...' /* fallback */
- if (tokens[idx].nesting === 1) {
- const hasRes: number[] = [] // level stack
- for (
- let i = idx + 1;
- !(tokens[i].nesting === -1
- && tokens[i].type === closeType);
- ++i
- ) {
- const token = tokens[i]
- if (token.type === 'list_item_open') {
- const result = resolveTreeNodeInfo(tokens, token, i)
- if (result) {
- hasRes.push(token.level)
- const [info, inline] = result
- const { filename, type, expanded, empty } = info
- const icon = getIcon(filename, type, attrs.icon)
-
- token.type = itemOpen
- token.tag = componentName
- token.attrSet('type', type)
- token.attrSet(':expanded', expanded ? 'true' : 'false')
- token.attrSet(':empty', empty ? 'true' : 'false')
- updateInlineToken(inline, info, icon)
- }
- else {
- hasRes.push(-1)
- }
- }
- else if (token.type === 'list_item_close') {
- if (token.level === hasRes.pop()) {
- token.type = itemClose
- token.tag = componentName
- }
- }
+ if (children.length === 0 && type === 'folder') {
+ children.push({ info: '…', level: level + 1, children: [] })
}
- const title = attrs.title
- return `${title ? `
${title}
` : ''}`
- }
- else {
- return '
'
- }
- }
- md.use(container, type, { render })
-}
-
-export function resolveTreeNodeInfo(
- tokens: Token[],
- current: Token,
- idx: number,
-): [FileTreeNode, Token] | undefined {
- let hasInline = false
- let hasChildren = false
- let inline!: Token
- for (
- let i = idx + 1;
- !(tokens[i].level === current.level && tokens[i].type === 'list_item_close');
- ++i
- ) {
- if (tokens[i].type === 'inline' && !hasInline) {
- inline = tokens[i]
- hasInline = true
- }
- else if (tokens[i].tag === 'ul') {
- hasChildren = true
- }
-
- if (hasInline && hasChildren)
- break
- }
-
- if (!hasInline)
- return undefined
-
- const children = inline.children!.filter(token => (token.type === 'text' && token.content) || token.tag === 'strong')
- const filename = children.filter(token => token.type === 'text').map(token => token.content).join(' ').split(/\s+/)[0]
- const focus = children[0]?.tag === 'strong'
- const type = hasChildren || filename.endsWith('/') ? 'folder' : 'file'
- const info: FileTreeNode = {
- filename: removeLeadingSlash(removeEndingSlash(filename)),
- type,
- focus,
- empty: !hasChildren,
- expanded: type === 'folder' && !filename.endsWith('/'),
- }
-
- return [info, inline] as const
-}
-
-export function updateInlineToken(inline: Token, info: FileTreeNode, icon: string) {
- const children = inline.children!
-
- const tokens: Token[] = []
- const wrapperOpen = new Token('span_open', 'span', 1)
- const wrapperClose = new Token('span_close', 'span', -1)
-
- wrapperOpen.attrSet('class', `tree-node ${info.type}`)
- tokens.push(wrapperOpen)
-
- if (info.filename !== '...' && info.filename !== '…') {
- const iconOpen = new Token('vp_iconify_open', 'VPIcon', 1)
- iconOpen.attrSet('name', icon)
- const iconClose = new Token('vp_iconify_close', 'VPIcon', -1)
-
- tokens.push(iconOpen, iconClose)
- }
-
- const fileOpen = new Token('span_open', 'span', 1)
- fileOpen.attrSet('class', `name${info.focus ? ' focus' : ''}`)
- tokens.push(fileOpen)
-
- let isStrongTag = false
- while (children.length) {
- const token = children.shift()!
- if (token.type === 'text' && token.content) {
- if (token.content.includes(' ')) {
- const [first, ...other] = token.content.split(' ')
- const text = new Token('text', '', 0)
- text.content = removeEndingSlash(first)
- tokens.push(text)
- const comment = new Token('text', '', 0)
- comment.content = other.join(' ')
- children.unshift(comment)
- }
- else {
- token.content = removeEndingSlash(token.content)
- tokens.push(token)
- }
- if (!isStrongTag)
- break
- }
- else if (token.tag === 'strong') {
- token.content = removeEndingSlash(token.content)
- tokens.push(token)
- if (token.nesting === 1) {
- isStrongTag = true
- }
- else {
- break
- }
- }
- else {
- tokens.push(token)
- }
- }
-
- const fileClose = new Token('span_close', 'span', -1)
- tokens.push(fileClose)
-
- if (children.filter(token => token.type === 'text' && token.content.trim()).length) {
- const commentOpen = new Token('span_open', 'span', 1)
- commentOpen.attrSet('class', 'comment')
- const commentClose = new Token('span_close', 'span', -1)
-
- tokens.push(commentOpen, ...children, commentClose)
- }
-
- tokens.push(wrapperClose)
- inline.children = tokens
+ const nodeType = children.length > 0 ? 'folder' : type
+ const renderedComment = comment
+ ? `${md.renderInline(comment.replaceAll('#', '\#'))}`
+ : ''
+ const renderedIcon = !isOmit
+ ? ``
+ : ''
+ const props = {
+ expanded: nodeType === 'folder' ? expanded : false,
+ focus,
+ type: nodeType,
+ filename,
+ }
+ return `
+${renderedIcon}${renderedComment}${children.length > 0 ? renderFileTree(children, meta) : ''}
+`
+ }).join('\n')
+
+ return createContainerSyntaxPlugin(
+ md,
+ 'file-tree',
+ (tokens, index) => {
+ const token = tokens[index]
+ const nodes = parseFileTreeRawContent(token.content)
+ const meta = token.meta as FileTreeAttrs
+ return `${
+ meta.title ? `
${meta.title}
` : ''
+ }${renderFileTree(nodes, meta)}
\n`
+ },
+ )
}
diff --git a/plugins/plugin-md-power/src/node/prepareConfigFile.ts b/plugins/plugin-md-power/src/node/prepareConfigFile.ts
index 621770e9..d4c6ac66 100644
--- a/plugins/plugin-md-power/src/node/prepareConfigFile.ts
+++ b/plugins/plugin-md-power/src/node/prepareConfigFile.ts
@@ -71,8 +71,8 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
}
if (options.fileTree) {
- imports.add(`import FileTreeItem from '${CLIENT_FOLDER}components/FileTreeItem.vue'`)
- enhances.add(`app.component('FileTreeItem', FileTreeItem)`)
+ imports.add(`import FileTreeNode from '${CLIENT_FOLDER}components/FileTreeNode.vue'`)
+ enhances.add(`app.component('FileTreeNode', FileTreeNode)`)
}
if (options.artPlayer) {