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`] = ` -"
-

files

-
-
-
" +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`] = ` +"
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

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 @@ + + + + + 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 + ? `` + : '' + 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) {