feat(plugin-md-power): add support file-tree container
This commit is contained in:
parent
cffb935b4f
commit
db5d81677f
143
plugins/plugin-md-power/src/client/components/FileTreeItem.vue
Normal file
143
plugins/plugin-md-power/src/client/components/FileTreeItem.vue
Normal 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>
|
||||
@ -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
|
||||
}
|
||||
775
plugins/plugin-md-power/src/node/features/fileTree/icons.ts
Normal file
775
plugins/plugin-md-power/src/node/features/fileTree/icons.ts
Normal file
File diff suppressed because one or more lines are too long
77
plugins/plugin-md-power/src/node/features/fileTree/index.ts
Normal file
77
plugins/plugin-md-power/src/node/features/fileTree/index.ts
Normal 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, '')
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
`\
|
||||
|
||||
@ -26,6 +26,7 @@ export interface MarkdownPowerPluginOptions {
|
||||
|
||||
// container
|
||||
repl?: false | ReplOptions
|
||||
fileTree?: boolean
|
||||
|
||||
caniuse?: boolean | CanIUseOptions
|
||||
}
|
||||
|
||||
@ -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 }) => ({
|
||||
|
||||
@ -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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user