feat(plugin-md-power): refactor file-tree container, close #565 (#572)

* feat(plugin-md-power): refactor file-tree container

* chore: tweak

* chore: tweak
This commit is contained in:
pengzhanbo 2025-04-28 00:15:15 +08:00 committed by GitHub
parent 7e255412c1
commit 599e43fd3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 512 additions and 422 deletions

View File

@ -1,66 +1,149 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`fileTreePlugin > should work with default options 1`] = ` exports[`fileTree > parseFileTreeRawContent > should work 1`] = `
"<div class="vp-file-tree"><ul> [
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-docs"></VPIcon><span class="name">docs</span></span> {
<ul> "children": [
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="flat-color-icons:info"></VPIcon><span class="name">README.md</span></span></FileTreeItem> {
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-markdown"></VPIcon><span class="name">foo.md</span></span></FileTreeItem> "children": [],
</ul> "info": "README.md",
</FileTreeItem> "level": 1,
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-src"></VPIcon><span class="name">src</span></span> },
<ul> {
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-client"></VPIcon><span class="name">client</span></span> "children": [],
<ul> "info": "foo.md",
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-component"></VPIcon><span class="name">components</span></span> "level": 1,
<ul> },
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-vue"></VPIcon><span class="name focus"><strong>Navbar.vue</strong></span></span></FileTreeItem> ],
</ul> "info": "docs",
</FileTreeItem> "level": 0,
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-typescript"></VPIcon><span class="name">index.ts</span><span class="comment"># comment</span></span></FileTreeItem> },
</ul> {
</FileTreeItem> "children": [
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:default-folder"></VPIcon><span class="name">node</span></span> {
<ul> "children": [
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-typescript"></VPIcon><span class="name">index.ts</span></span></FileTreeItem> {
</ul> "children": [
</FileTreeItem> {
</ul> "children": [],
</FileTreeItem> "info": "**Navbar.vue**",
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-git"></VPIcon><span class="name">.gitignore</span></span></FileTreeItem> "level": 3,
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-node"></VPIcon><span class="name">package.json</span></span></FileTreeItem> },
</ul> ],
</div><div class="vp-file-tree"><p class="vp-file-tree-title">files</p><ul> "info": "components",
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-src"></VPIcon><span class="name">src</span></span> "level": 2,
<ul> },
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:default-folder"></VPIcon><span class="name">js</span></span> {
<ul> "children": [],
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><span class="name">…</span></span></FileTreeItem> "info": "index.ts # comment",
</ul> "level": 2,
</FileTreeItem> },
<FileTreeItem type="folder" :expanded="false" :empty="true"><span class="tree-node folder"><VPIcon name="vscode-icons:default-folder"></VPIcon><span class="name">vue</span></span></FileTreeItem> ],
<FileTreeItem type="folder" :expanded="false" :empty="true"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-css"></VPIcon><span class="name">css</span></span></FileTreeItem> "info": "client",
</ul> "level": 1,
</FileTreeItem> },
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="flat-color-icons:info"></VPIcon><span class="name">README.md</span></span></FileTreeItem> {
</ul> "children": [
</div><div class="vp-file-tree"><ul> {
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:default-file"></VPIcon><span class="name">docs</span></span></FileTreeItem> "children": [],
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:default-folder"></VPIcon><span class="name">src</span></span> "info": "index.ts",
<ul> "level": 2,
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:default-file"></VPIcon><span class="name">a.js</span></span></FileTreeItem> },
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:default-file"></VPIcon><span class="name">b.ts</span></span></FileTreeItem> ],
</ul> "info": "node",
</FileTreeItem> "level": 1,
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:default-file"></VPIcon><span class="name">README.md</span></span></FileTreeItem> },
</ul> ],
</div><div class="vp-file-tree"><ul> "info": "src",
<li></li> "level": 0,
<li> },
<ul> {
<li></li> "children": [],
</ul> "info": ".gitignore",
</li> "level": 0,
</ul> },
</div><div class="vp-file-tree"></div>" {
"children": [],
"info": "package.json",
"level": 0,
},
]
`;
exports[`fileTreePlugin > should work with default options 1`] = `
"<div class="vp-file-tree"><FileTreeNode expanded type="folder" filename="docs">
<template #icon><VPIcon name="vscode-icons:folder-type-docs" /></template><FileTreeNode type="file" filename="README.md">
<template #icon><VPIcon name="flat-color-icons:info" /></template>
</FileTreeNode>
<FileTreeNode type="file" filename="foo.md">
<template #icon><VPIcon name="vscode-icons:file-type-markdown" /></template>
</FileTreeNode>
</FileTreeNode>
<FileTreeNode expanded type="folder" filename="src">
<template #icon><VPIcon name="vscode-icons:folder-type-src" /></template><FileTreeNode expanded type="folder" filename="client">
<template #icon><VPIcon name="vscode-icons:folder-type-client" /></template><FileTreeNode expanded type="folder" filename="components">
<template #icon><VPIcon name="vscode-icons:folder-type-component" /></template><FileTreeNode focus type="file" filename="Navbar.vue">
<template #icon><VPIcon name="vscode-icons:file-type-vue" /></template>
</FileTreeNode>
</FileTreeNode>
<FileTreeNode type="file" filename="index.ts">
<template #icon><VPIcon name="vscode-icons:file-type-typescript" /></template><template #comment># comment</template>
</FileTreeNode>
</FileTreeNode>
<FileTreeNode expanded type="folder" filename="node">
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="index.ts">
<template #icon><VPIcon name="vscode-icons:file-type-typescript" /></template>
</FileTreeNode>
</FileTreeNode>
</FileTreeNode>
<FileTreeNode type="file" filename=".gitignore">
<template #icon><VPIcon name="vscode-icons:file-type-git" /></template>
</FileTreeNode>
<FileTreeNode type="file" filename="package.json">
<template #icon><VPIcon name="vscode-icons:file-type-node" /></template>
</FileTreeNode></div>
<div class="vp-file-tree"><p class="vp-file-tree-title">files</p><FileTreeNode expanded type="folder" filename="src">
<template #icon><VPIcon name="vscode-icons:folder-type-src" /></template><FileTreeNode expanded type="folder" filename="js">
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="…">
</FileTreeNode>
</FileTreeNode>
<FileTreeNode type="folder" filename="vue">
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="…">
</FileTreeNode>
</FileTreeNode>
<FileTreeNode type="folder" filename="css">
<template #icon><VPIcon name="vscode-icons:folder-type-css" /></template><FileTreeNode type="file" filename="…">
</FileTreeNode>
</FileTreeNode>
</FileTreeNode>
<FileTreeNode type="file" filename="README.md">
<template #icon><VPIcon name="flat-color-icons:info" /></template>
</FileTreeNode></div>
<div class="vp-file-tree"><FileTreeNode type="file" filename="docs">
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
</FileTreeNode>
<FileTreeNode expanded type="folder" filename="src">
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="a.js">
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
</FileTreeNode>
<FileTreeNode type="file" filename="b.ts">
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
</FileTreeNode>
</FileTreeNode>
<FileTreeNode type="file" filename="README.md">
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
</FileTreeNode></div>
<div class="vp-file-tree"><FileTreeNode type="file" filename="">
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
</FileTreeNode>
<FileTreeNode expanded type="folder" filename="">
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="">
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
</FileTreeNode>
</FileTreeNode></div>
<div class="vp-file-tree"></div>
"
`; `;

View File

@ -1,7 +1,59 @@
import type { FileTreeOptions } from '../src/shared/fileTree.js' import type { FileTreeOptions } from '../src/shared/fileTree.js'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest' 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) { function createMarkdown(options?: FileTreeOptions) {
return new MarkdownIt().use(fileTreePlugin, options) return new MarkdownIt().use(fileTreePlugin, options)

View File

@ -1,183 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
type: 'file' | 'folder'
expanded: boolean
empty: boolean
}>()
const active = ref(!!props.expanded)
const el = ref<HTMLElement>()
function toggle(e: HTMLElementEventMap['click']) {
const target = e.target as HTMLElement
if (target.matches('.comment') || e.currentTarget === target)
return
active.value = !active.value
}
onMounted(() => {
if (!el.value || props.type !== 'folder')
return
el.value.querySelector('.tree-node.folder')?.addEventListener(
'click',
toggle as EventListener,
)
})
onUnmounted(() => {
if (!el.value || props.type !== 'folder')
return
el.value.querySelector('.tree-node.folder')?.removeEventListener(
'click',
toggle as EventListener,
)
})
</script>
<template>
<li ref="el" class="file-tree-item" :class="{ expanded: active }">
<slot />
<ul v-if="props.type === 'folder' && props.empty">
<li class="file-tree-item">
<span class="tree-node file">
<span class="name"></span>
</span>
</li>
</ul>
</li>
</template>
<style>
.vp-file-tree {
width: fit-content;
max-width: 100%;
padding: 16px;
font-size: 14px;
background-color: var(--vp-c-bg-safe);
border: solid 1px var(--vp-c-divider);
border-radius: 8px;
transition: border var(--vp-t-color), background-color var(--vp-t-color);
}
.vp-file-tree .vp-file-tree-title {
padding-left: 16px;
margin: -16px -16px 0;
font-weight: bold;
color: var(--vp-c-text-1);
border-bottom: solid 1px var(--vp-c-divider);
transition: color var(--vp-t-color), border-color var(--vp-t-color);
}
.vp-file-tree ul {
padding: 0 !important;
margin: 0 !important;
list-style: none !important;
}
.file-tree-item {
margin-left: 14px;
}
.vp-file-tree .file-tree-item {
margin-top: 0;
}
.file-tree-item .tree-node {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
margin: 4px 0;
}
.file-tree-item .tree-node .name {
font-family: var(--vp-font-family-mono);
}
.file-tree-item .tree-node.folder {
position: relative;
}
.file-tree-item .tree-node.folder > .name {
color: var(--vp-c-text-1);
cursor: pointer;
transition: color var(--vp-t-color);
}
.file-tree-item .tree-node.folder > .name:hover {
color: var(--vp-c-brand-1);
}
.file-tree-item .tree-node.folder::before {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M5.536 21.886a1 1 0 0 0 1.033-.064l13-9a1 1 0 0 0 0-1.644l-13-9A1 1 0 0 0 5 3v18a1 1 0 0 0 .536.886'/%3E%3C/svg%3E");
position: absolute;
top: 7px;
left: -14px;
display: block;
width: 10px;
height: 10px;
color: var(--vp-c-text-3);
cursor: pointer;
content: "";
background-color: currentcolor;
-webkit-mask: var(--icon) no-repeat;
mask: var(--icon) no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
transition: color var(--vp-t-color);
}
.file-tree-item .tree-node .name.focus {
position: relative;
padding: 0 4px;
margin: 0 -4px;
font-weight: bold;
color: var(--vp-c-bg);
background-color: var(--vp-c-brand-2);
border-radius: 4px;
transition: color var(--vp-t-color), background-color var(--vp-t-color);
}
.file-tree-item .tree-node .name.focus:hover {
color: var(--vp-c-bg);
background-color: var(--vp-c-brand-1);
}
.file-tree-item .tree-node .comment {
margin-left: 20px;
overflow: hidden;
color: var(--vp-c-text-3);
transition: color var(--vp-t-color);
}
.file-tree-item .tree-node [class*="vpi-"] {
width: 1.2em;
height: 1.2em;
margin: 0;
}
.file-tree-item .tree-node.folder [class*="vpi-"] {
cursor: pointer;
}
.vp-file-tree .file-tree-item > ul {
padding-left: 8px !important;
margin: 0 0 0 6px !important;
border-left: solid 1px var(--vp-c-divider);
transition: border-color var(--vp-t-color);
}
.file-tree-item:not(.expanded) > ul {
display: none;
}
.file-tree-item.expanded > .tree-node.folder::before {
transform: rotate(90deg);
}
</style>

View File

@ -0,0 +1,211 @@
<script setup lang="ts">
import type { Ref } from 'vue'
import { FadeInExpandTransition } from '@vuepress/helper/client'
import { inject, ref } from 'vue'
import '@vuepress/helper/transition/fade-in-height-expand.css'
const props = defineProps<{
type: 'file' | 'folder'
filename: string
expanded?: boolean
focus?: boolean
}>()
const activeFileTreeNode = inject<Ref<string>>('active-file-tree-node', ref(''))
const onNodeClick = inject<
(filename: string, type: 'file' | 'folder') => void
>('on-file-tree-node-click', () => {})
const active = ref(props.expanded)
function nodeClick() {
if (props.filename === '…' || props.filename === '...')
return
onNodeClick(props.filename, props.type)
}
function toggle(ev: MouseEvent) {
if (props.type === 'folder') {
const el = ev.target as HTMLElement
if (!el.matches('.comment, .comment *')) {
active.value = !active.value
nodeClick()
}
}
else {
nodeClick()
}
}
</script>
<template>
<div class="vp-file-tree-node">
<p
class="vp-file-tree-info" :class="{
[type]: true,
focus,
expanded: type === 'folder' ? active : false,
active: type === 'file' ? activeFileTreeNode === filename : false,
}"
@click="toggle"
>
<slot name="icon" />
<span class="name" :class="[type]">{{ filename }}</span>
<span v-if="$slots.comment" class="comment"><slot name="comment" /></span>
</p>
<FadeInExpandTransition>
<div v-if="type === 'folder'" v-show="active" class="group">
<slot />
</div>
</FadeInExpandTransition>
</div>
</template>
<style>
.vp-file-tree {
max-width: 100%;
padding: 16px;
overflow: auto hidden;
font-size: 14px;
background-color: var(--vp-c-bg-safe);
border: solid 1px var(--vp-c-divider);
border-radius: 8px;
transition: border var(--vp-t-color), background-color var(--vp-t-color);
}
.vp-file-tree .vp-file-tree-title {
padding: 8px 16px;
margin: -16px -16px 8px;
font-weight: bold;
color: var(--vp-c-text-1);
border-bottom: solid 1px var(--vp-c-divider);
transition: color var(--vp-t-color), border-color var(--vp-t-color);
}
.vp-file-tree .vp-file-tree-info {
position: relative;
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-start;
height: 28px;
padding: 2px 0;
margin: 0 0 0 16px;
line-height: 24px;
text-wrap: nowrap;
}
.vp-file-tree .vp-file-tree-info::after {
position: absolute;
top: 1px;
right: 0;
bottom: 1px;
left: -16px;
z-index: 0;
display: block;
pointer-events: none;
content: "";
background-color: transparent;
border-radius: 6px;
transition: background-color var(--vp-t-color);
}
.vp-file-tree .vp-file-tree-info.active::after,
.vp-file-tree .vp-file-tree-info:hover::after {
background-color: var(--vp-c-default-soft);
}
.vp-file-tree .vp-file-tree-info.folder {
cursor: pointer;
}
.vp-file-tree .vp-file-tree-info.folder::before {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%23000' d='m5.157 13.069l4.611-4.685a.546.546 0 0 0 0-.768L5.158 2.93a.55.55 0 0 1 0-.771a.53.53 0 0 1 .759 0l4.61 4.684a1.65 1.65 0 0 1 0 2.312l-4.61 4.684a.53.53 0 0 1-.76 0a.55.55 0 0 1 0-.771'/%3E%3C/svg%3E");
position: absolute;
top: 8px;
left: -16px;
display: block;
width: 12px;
height: 12px;
color: var(--vp-c-text-2);
cursor: pointer;
content: "";
background-color: currentcolor;
-webkit-mask: var(--icon) no-repeat;
mask: var(--icon) no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
transition: color var(--vp-t-color), transform var(--vp-t-color);
transform: rotate(0);
}
.vp-file-tree .vp-file-tree-info.folder.expanded::before {
transform: rotate(90deg);
}
.vp-file-tree .vp-file-tree-info .name {
position: relative;
font-family: var(--vp-font-family-mono);
}
.vp-file-tree .vp-file-tree-info.folder .name {
color: var(--vp-c-text-1);
transition: color var(--vp-t-color);
}
.vp-file-tree .vp-file-tree-info.focus .name {
padding: 0 4px;
margin: 0 -4px;
font-weight: bold;
color: var(--vp-c-bg);
background-color: var(--vp-c-brand-2);
border-radius: 4px;
transition: color var(--vp-t-color), background-color var(--vp-t-color);
}
.vp-file-tree .vp-file-tree-info.active .name {
color: var(--vp-c-brand-1);
}
.vp-file-tree .vp-file-tree-info:not(.focus).folder .name:hover {
color: var(--vp-c-brand-1);
}
.vp-file-tree .vp-file-tree-info .comment {
display: inline-block;
flex: 1 2;
height: 28px;
padding-right: 16px;
padding-left: 20px;
margin: -2px 0;
color: var(--vp-c-text-3);
cursor: auto;
transition: color var(--vp-t-color);
}
.vp-file-tree .vp-file-tree-node .group {
position: relative;
margin-left: 28px;
}
.vp-file-tree .vp-file-tree-node .group::before {
position: absolute;
top: 0;
left: -4px;
width: 1px;
height: 100%;
content: "";
background-color: var(--vp-c-divider);
transition: background-color var(--vp-t-color);
}
.vp-file-tree [class*="vpi-"] {
width: 1.2em;
height: 1.2em;
margin: 0;
}
</style>

View File

@ -1,17 +1,14 @@
import type { Markdown } from 'vuepress/markdown' import type { Markdown } from 'vuepress/markdown'
import type { FileTreeIconMode, FileTreeOptions } from '../../shared/index.js' import type { FileTreeIconMode, FileTreeOptions } from '../../shared/index.js'
import container from 'markdown-it-container' import { removeEndingSlash } from 'vuepress/shared'
import Token from 'markdown-it/lib/token.mjs'
import { removeEndingSlash, removeLeadingSlash } from 'vuepress/shared'
import { defaultFile, defaultFolder, getFileIcon } from '../fileIcons/index.js' 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 { interface FileTreeNode {
filename: string info: string
type: 'folder' | 'file' level: number
expanded: boolean children: FileTreeNode[]
focus: boolean
empty: boolean
} }
interface FileTreeAttrs { interface FileTreeAttrs {
@ -19,11 +16,63 @@ interface FileTreeAttrs {
icon?: FileTreeIconMode icon?: FileTreeIconMode
} }
const type = 'file-tree' export function parseFileTreeRawContent(content: string): FileTreeNode[] {
const closeType = `container_${type}_close` const root: FileTreeNode = { info: '', level: -1, children: [] }
const componentName = 'FileTreeItem' const stack: FileTreeNode[] = [root]
const itemOpen = 'file_tree_item_open' const lines = content.trim().split('\n')
const itemClose = 'file_tree_item_close' 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 = {}) { export function fileTreePlugin(md: Markdown, options: FileTreeOptions = {}) {
const getIcon = (filename: string, type: 'folder' | 'file', mode?: FileTreeIconMode): string => { 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) return getFileIcon(filename, type)
} }
const render = (tokens: Token[], idx: number): string => { const renderFileTree = (nodes: FileTreeNode[], meta: FileTreeAttrs): string =>
const { attrs } = resolveAttrs<FileTreeAttrs>(tokens[idx].info.slice(type.length - 1)) 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) { if (children.length === 0 && type === 'folder') {
const hasRes: number[] = [] // level stack children.push({ info: '…', level: level + 1, children: [] })
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
}
}
} }
const title = attrs.title
return `<div class="vp-file-tree">${title ? `<p class="vp-file-tree-title">${title}</p>` : ''}`
}
else {
return '</div>'
}
}
md.use(container, type, { render }) const nodeType = children.length > 0 ? 'folder' : type
} const renderedComment = comment
? `<template #comment>${md.renderInline(comment.replaceAll('#', '\#'))}</template>`
export function resolveTreeNodeInfo( : ''
tokens: Token[], const renderedIcon = !isOmit
current: Token, ? `<template #icon><VPIcon name="${getIcon(filename, nodeType, meta.icon)}" /></template>`
idx: number, : ''
): [FileTreeNode, Token] | undefined { const props = {
let hasInline = false expanded: nodeType === 'folder' ? expanded : false,
let hasChildren = false focus,
let inline!: Token type: nodeType,
for ( filename,
let i = idx + 1; }
!(tokens[i].level === current.level && tokens[i].type === 'list_item_close'); return `<FileTreeNode${stringifyAttrs(props)}>
++i ${renderedIcon}${renderedComment}${children.length > 0 ? renderFileTree(children, meta) : ''}
) { </FileTreeNode>`
if (tokens[i].type === 'inline' && !hasInline) { }).join('\n')
inline = tokens[i]
hasInline = true return createContainerSyntaxPlugin(
} md,
else if (tokens[i].tag === 'ul') { 'file-tree',
hasChildren = true (tokens, index) => {
} const token = tokens[index]
const nodes = parseFileTreeRawContent(token.content)
if (hasInline && hasChildren) const meta = token.meta as FileTreeAttrs
break return `<div class="vp-file-tree">${
} meta.title ? `<p class="vp-file-tree-title">${meta.title}</p>` : ''
}${renderFileTree(nodes, meta)}</div>\n`
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
} }

View File

@ -71,8 +71,8 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
} }
if (options.fileTree) { if (options.fileTree) {
imports.add(`import FileTreeItem from '${CLIENT_FOLDER}components/FileTreeItem.vue'`) imports.add(`import FileTreeNode from '${CLIENT_FOLDER}components/FileTreeNode.vue'`)
enhances.add(`app.component('FileTreeItem', FileTreeItem)`) enhances.add(`app.component('FileTreeNode', FileTreeNode)`)
} }
if (options.artPlayer) { if (options.artPlayer) {