feat(plugin-md-power): add support file-tree container

This commit is contained in:
pengzhanbo 2024-09-01 11:40:50 +08:00
parent cffb935b4f
commit db5d81677f
10 changed files with 1212 additions and 1 deletions

View File

@ -0,0 +1,143 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const props = defineProps<{
type: 'file' | 'folder'
expanded: boolean
empty: boolean
}>()
const active = ref(!!props.expanded)
const el = ref<HTMLElement>()
onMounted(() => {
if (!el.value || props.type !== 'folder')
return
el.value.querySelector('.tree-node.folder')?.addEventListener('click', () => {
active.value = !active.value
})
})
</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(--t-color), background-color var(--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;
color: var(--vp-c-text-1);
cursor: pointer;
transition: color var(--t-color);
}
.file-tree-item .tree-node.folder: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-2);
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(--t-color);
}
.file-tree-item .tree-node.file .name.focus {
font-weight: bold;
color: var(--vp-c-brand-1);
transition: color var(--t-color);
}
.file-tree-item .tree-node.file .comment {
margin-left: 20px;
overflow: hidden;
color: var(--vp-c-text-3);
}
.file-tree-item .tree-node [class*="vp-fti-"] {
display: inline-block;
width: 1em;
height: 1em;
color: inherit;
background-color: currentcolor;
-webkit-mask: var(--icon) no-repeat;
mask: var(--icon) no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.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(--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,74 @@
import { FileIcons, definitions } from './icons.js'
export interface FileIcon {
name: string
svg: string
}
export const defaultFileIcon: FileIcon = {
name: 'default',
svg: makeSVGIcon(FileIcons['seti:default']),
}
export const folderIcon: FileIcon = {
name: 'folder',
svg: makeSVGIcon(FileIcons['seti:folder']),
}
export function getFileIcon(fileName: string): FileIcon {
const name = getFileIconName(fileName)
if (!name)
return defaultFileIcon
if (name in FileIcons) {
const path = FileIcons[name as keyof typeof FileIcons]
return {
name: name.includes(':') ? name.split(':')[1] : name,
svg: makeSVGIcon(path),
}
}
return defaultFileIcon
}
function makeSVGIcon(svg: string): string {
svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${svg}</svg>`
.replace(/"/g, '\'')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
.replace(/\{/g, '%7B')
.replace(/\}/g, '%7D')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
return `url("data:image/svg+xml,${svg}")`
}
function getFileIconName(fileName: string) {
let icon: string | undefined = definitions.files[fileName]
if (icon)
return icon
icon = getFileIconTypeFromExtension(fileName)
if (icon)
return icon
for (const [partial, partialIcon] of Object.entries(definitions.partials)) {
if (fileName.includes(partial))
return partialIcon
}
return icon
}
function getFileIconTypeFromExtension(fileName: string): string | undefined {
const firstDotIndex = fileName.indexOf('.')
if (firstDotIndex === -1)
return
let extension = fileName.slice(firstDotIndex)
while (extension !== '') {
const icon = definitions.extensions[extension]
if (icon)
return icon
const nextDotIndex = extension.indexOf('.', 1)
if (nextDotIndex === -1)
return
extension = extension.slice(nextDotIndex)
}
return undefined
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,77 @@
import fs from 'node:fs'
import container from 'markdown-it-container'
import type { Markdown } from 'vuepress/markdown'
import type Token from 'markdown-it/lib/token.mjs'
import type { App } from 'vuepress/core'
import { resolveTreeNodeInfo, updateInlineToken } from './resolveTreeNodeInfo.js'
import { type FileIcon, folderIcon, getFileIcon } from './findIcon.js'
const type = 'file-tree'
const closeType = `container_${type}_close`
const componentName = 'FileTreeItem'
const itemOpen = 'file_tree_item_open'
const itemClose = 'file_tree_item_close'
const classPrefix = 'vp-fti-'
const styleFilepath = 'internal/md-power/file-tree.css'
export async function fileTreePlugin(app: App, md: Markdown) {
const validate = (info: string): boolean => info.trim().startsWith(type)
const render = (tokens: Token[], idx: number): string => {
if (tokens[idx].nesting === 1) {
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) {
const [info, inline] = result
const { filename, type, expanded, empty } = info
const icon = type === 'file' ? getFileIcon(filename) : folderIcon
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, `${classPrefix}${icon.name}`)
addIcon(icon)
}
}
else if (token.type === 'list_item_close') {
token.type = itemClose
token.tag = componentName
}
}
return '<div class="vp-file-tree">'
}
else {
return '</div>'
}
}
let timer: NodeJS.Timeout | null = null
const icons: Record<string, FileIcon> = {}
function addIcon(icon: FileIcon) {
icons[icon.name] = icon
if (timer)
clearTimeout(timer)
timer = setTimeout(async () => {
let content = ''
for (const icon of Object.values(icons)) {
content += `.${classPrefix}${icon.name} { --icon: ${icon.svg}; }\n`
}
await app.writeTemp(styleFilepath, content)
}, 300)
}
md.use(container, type, { validate, render })
if (!fs.existsSync(app.dir.temp(styleFilepath)))
await app.writeTemp(styleFilepath, '')
}

View File

@ -0,0 +1,125 @@
import { removeLeadingSlash } from 'vuepress/shared'
import Token from 'markdown-it/lib/token.mjs'
interface FileTreeNode {
filename: string
type: 'folder' | 'file'
expanded: boolean
focus: boolean
empty: boolean
}
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(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
if (!children)
return
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('span_open', 'span', 1)
iconOpen.attrSet('class', icon)
const iconClose = new Token('span_close', 'span', -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 = first
tokens.push(text)
const comment = new Token('text', '', 0)
comment.content = other.join(' ')
children.unshift(comment)
}
else {
tokens.push(token)
}
if (!isStrongTag)
break
}
else if (token.tag === 'strong') {
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

@ -14,6 +14,7 @@ import { jsfiddlePlugin } from './features/jsfiddle.js'
import { plotPlugin } from './features/plot.js'
import { langReplPlugin } from './features/langRepl.js'
import { prepareConfigFile } from './prepareConfigFile.js'
import { fileTreePlugin } from './features/fileTree/index.js'
export function markdownPowerPlugin(options: MarkdownPowerPluginOptions = {}): Plugin {
return (app) => {
@ -89,12 +90,17 @@ export function markdownPowerPlugin(options: MarkdownPowerPluginOptions = {}): P
options.plot === true
|| (typeof options.plot === 'object' && options.plot.tag !== false)
) {
// =|plot|=
// !!plot!!
md.use(plotPlugin)
}
if (options.repl)
await langReplPlugin(app, md, options.repl)
if (options.fileTree) {
// ::: file-tree
await fileTreePlugin(app, md)
}
},
}
}

View File

@ -54,6 +54,12 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
enhances.add(`app.component('CanIUseViewer', CanIUse)`)
}
if (options.fileTree) {
imports.add(`import FileTreeItem from '${CLIENT_FOLDER}components/FileTreeItem.vue'`)
imports.add(`import '@internal/md-power/file-tree.css'`)
enhances.add(`app.component('FileTreeItem', FileTreeItem)`)
}
return app.writeTemp(
'md-power/config.js',
`\

View File

@ -26,6 +26,7 @@ export interface MarkdownPowerPluginOptions {
// container
repl?: false | ReplOptions
fileTree?: boolean
caniuse?: boolean | CanIUseOptions
}

View File

@ -33,6 +33,7 @@ export default defineConfig(() => {
entry: ['./src/node/index.ts'],
outDir: './lib/node',
target: 'node18',
external: ['markdown-it'],
},
// client
...config.map(({ dir, files }) => ({

View File

@ -131,6 +131,9 @@ export function getPlugins({
if (pluginOptions.markdownPower !== false) {
plugins.push(markdownPowerPlugin({
caniuse: pluginOptions.caniuse,
fileTree: true,
plot: true,
icons: true,
...pluginOptions.markdownPower || {},
repl: pluginOptions.markdownPower?.repl
? { theme: shikiTheme, ...pluginOptions.markdownPower?.repl }