feat(plugin-md-power): add collapse syntax support (#535)

This commit is contained in:
pengzhanbo 2025-03-23 18:29:45 +08:00 committed by GitHub
parent dd5c984578
commit cca923a235
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 875 additions and 8 deletions

View File

@ -45,6 +45,7 @@ export const themeGuide = defineNoteConfig({
'tabs',
'timeline',
'demo-wrapper',
'collapse',
'npm-to',
'caniuse',
'include',

View File

@ -29,6 +29,7 @@ export const theme: Theme = plumeTheme({
annotation: true,
abbr: true,
timeline: true,
collapse: true,
imageSize: 'all',
pdf: true,
caniuse: true,

View File

@ -161,6 +161,12 @@ export default defineUserConfig({
- **默认值**: `false`
- **详情**: 是否启用时间线容器语法
### collapse
- **类型**: `boolean`
- **默认值**: `false`
- **详情**: 是否启用折叠面板容器语法
### demo
- **类型**: `boolean`

View File

@ -0,0 +1,269 @@
---
title: 折叠面板
icon: carbon:collapse-categories
createTime: 2025/03/22 22:27:22
permalink: /guide/markdown/collapse/
badge:
type: tip
text: 1.0.0-rc.137 +
---
## 概述
在 markdown 中,使用 `::: collapse` 容器,包含 markdown 无序列表语法,实现 ==折叠面板== 。
- 支持通过 `accordion` 设置为 ==手风琴== 模式
## 启用
该功能默认不启用,你需要在 `theme` 配置中启用。
```ts title=".vuepress/config.ts"
export default defineUserConfig({
theme: plumeTheme({
markdown: {
collapse: true, // [!code ++]
}
})
})
```
## 使用
在 markdown 中,使用 `::: collapse` 容器,包含 markdown 无序列表语法,每一项为一个单独的可折叠区域。
```md title="collapse.md"
::: collapse
- 标题 1 <!-- 标题,点击控制 展开/折叠 -->
<!-- 标题与内容必须空一行 -->
内容 <!-- 内容,被折叠的区域-->
- 标题 2
内容
:::
```
对于列表的每一个项:
- 从 __首行开始____首个空行__,均为 __标题__
- __首个空行之后__: 正文内容
:::important 请注意添加正确的缩进
:::
__一个简单的例子__
__输入__
```md
::: collapse
- 标题 1
正文内容
- 标题 2
正文内容
:::
```
__输出__
::: collapse
- 标题 1
正文内容
- 标题 2
正文内容
:::
## 配置
`::: collapse` 容器语法之后,跟随配置项:
- `accordion` :折叠面板设置为 ==手风琴== 模式,在手风琴模式下,只允许展开一个面板,点击其他面板会关闭之前的面板。
- `expand` :默认展开面板,在手风琴模式下无效。
在列表项,标题之前,可通过特殊标记 `:+` / `:-` 来设置当前项是否 __展开 / 折叠__
## 示例
### 基本用法
__输入__
```md
::: collapse
- 标题 1
正文内容
- 标题 2
正文内容
:::
```
__输出__
::: collapse
- 标题 1
正文内容
- 标题 2
正文内容
:::
### 默认全部展开
添加 `expand` 选项,默认展开所有面板
__输入__
```md /expand/
::: collapse expand
- 标题 1
正文内容
- 标题 2
正文内容
:::
```
__输出__
::: collapse expand
- 标题 1
正文内容
- 标题 2
正文内容
:::
### 手风琴模式
添加 `accordion` 选项,设置为手风琴模式,只允许展开一个面板,点击其他面板会关闭之前的面板
```md /accordion/
::: collapse accordion
- 标题 1
正文内容
- 标题 2
正文内容
- 标题 3
正文内容
:::
```
__输出__
::: collapse accordion
- 标题 1
正文内容
- 标题 2
正文内容
- 标题 3
正文内容
:::
### `:+` 标记项为展开
折叠面板默认全部关闭,可以使用 `:+` 标记项初始状态为展开。
__输入__
```md /:+/
::: collapse
- 标题 1
正文内容
- :+ 标题 2
正文内容
- :+ 标题 3
正文内容
:::
```
__输出__
::: collapse
- 标题 1
正文内容
- :+ 标题 2
正文内容
- :+ 标题 3
正文内容
:::
### `:-` 标记项为折叠
折叠面板配置 `expand` 时默认全部展开,可以使用 `:-` 标记项初始状态为折叠。
__输入__
```md /:-/
::: collapse expand
- 标题 1
正文内容
- :- 标题 2
正文内容
- 标题 3
正文内容
:::
```
__输出__
::: collapse expand
- 标题 1
正文内容
- :- 标题 2
正文内容
- 标题 3
正文内容
:::

View File

@ -0,0 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`collapsePlugin > should work 1`] = `
"<VPCollapse><VPCollapseItem expand :index="0"><template #title>标题</template><p>内容</p>
</VPCollapseItem><VPCollapseItem :index="1"><template #title><code>code</code>标题</template><p>内容</p>
<ul>
<li>列表 1</li>
<li>列表 2</li>
</ul>
</VPCollapseItem><VPCollapseItem :index="2"><template #title><code>code</code> 标题</template><p>内容</p>
</VPCollapseItem></VPCollapse>"
`;
exports[`collapsePlugin > should work with accordion 1`] = `
"<VPCollapse accordion><VPCollapseItem :index="0"><template #title>标题</template><p>内容</p>
</VPCollapseItem><VPCollapseItem :index="1"><template #title>标题</template><p>内容</p>
</VPCollapseItem><VPCollapseItem :index="2"><template #title>标题</template><p>内容</p>
</VPCollapseItem></VPCollapse><VPCollapse accordion :index="0"><VPCollapseItem :index="0"><template #title>标题</template><p>内容</p>
</VPCollapseItem><VPCollapseItem :index="1"><template #title>标题</template><p>内容</p>
</VPCollapseItem><VPCollapseItem :index="2"><template #title>标题</template><p>内容</p>
</VPCollapseItem></VPCollapse><VPCollapse accordion :index="1"><VPCollapseItem :index="0"><template #title>标题</template><p>内容</p>
</VPCollapseItem><VPCollapseItem expand :index="1"><template #title>标题</template><p>内容</p>
</VPCollapseItem><VPCollapseItem :index="2"><template #title>标题</template><p>内容</p>
</VPCollapseItem></VPCollapse>"
`;
exports[`collapsePlugin > should work with expand 1`] = `
"<VPCollapse><VPCollapseItem expand :index="0"><template #title>标题</template><p>内容</p>
</VPCollapseItem><VPCollapseItem expand :index="1"><template #title>标题</template><p>内容</p>
</VPCollapseItem><VPCollapseItem :index="2"><template #title>标题</template><p>内容</p>
</VPCollapseItem></VPCollapse>"
`;

View File

@ -0,0 +1,93 @@
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
import { collapsePlugin } from '../src/node/container/collapse.js'
describe('collapsePlugin', () => {
const md = new MarkdownIt().use(collapsePlugin)
it('should work', () => {
const code = `\
::: collapse
- :+
- :- \`code\`标题
- 1
- 2
- \`code\` 标题
:::
`
expect(md.render(code)).toMatchSnapshot()
})
it('should work with expand', () => {
const code = `\
::: collapse expand
-
-
- :-
:::
`
expect(md.render(code)).toMatchSnapshot()
})
it('should work with accordion', () => {
const code = `\
::: collapse accordion
-
-
-
:::
::: collapse accordion expand
-
-
-
:::
::: collapse accordion
-
- :+
-
:::
`
expect(md.render(code)).toMatchSnapshot()
})
})

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { provide, ref } from 'vue'
import { INJECT_COLLAPSE_KEY } from '../options.js'
const props = defineProps<{
accordion?: boolean
index?: number
}>()
const currentIndex = ref<number | undefined>(props.index)
provide(INJECT_COLLAPSE_KEY, {
accordion: props.accordion ?? false,
index: currentIndex,
})
</script>
<template>
<div class="vp-collapse">
<slot />
</div>
</template>
<style>
.vp-collapse {
display: flex;
flex-direction: column;
gap: 16px;
margin: 16px 0;
}
</style>

View File

@ -0,0 +1,118 @@
<script setup lang="ts">
import type { Ref } from 'vue'
import { inject, ref, watch } from 'vue'
import { INJECT_COLLAPSE_KEY } from '../options.js'
import VPFadeInExpandTransition from './VPFadeInExpandTransition.vue'
const props = defineProps<{
expand?: boolean
index: number
}>()
const collapse = inject<{
accordion: boolean
index: Ref<number | undefined>
}>(INJECT_COLLAPSE_KEY)
if (__VUEPRESS_DEV__ && !collapse) {
throw new Error('<VPCollapseItem /> must be used inside <VPCollapse />')
}
const expand = ref(
collapse?.accordion && typeof collapse.index.value !== 'undefined'
? props.index === collapse.index.value
: props.expand,
)
if (collapse?.accordion) {
watch(collapse?.index, () => {
expand.value = collapse?.index.value === props.index
})
}
function toggle() {
if (collapse?.accordion) {
if (collapse.index.value === props.index && expand.value) {
expand.value = false
}
else {
collapse!.index.value = props.index!
expand.value = true
}
}
else {
expand.value = !expand.value
}
}
</script>
<template>
<div class="vp-collapse-item" :class="{ expand }">
<div class="vp-collapse-header" @click="toggle">
<span class="vpi-chevron-right" />
<p class="vp-collapse-title">
<slot name="title" />
</p>
</div>
<VPFadeInExpandTransition>
<div v-show="expand" class="vp-collapse-content">
<div class="vp-collapse-content-inner">
<slot />
</div>
</div>
</VPFadeInExpandTransition>
</div>
</template>
<style>
.vp-collapse-item {
display: flex;
flex-direction: column;
padding-top: 16px;
border-top: solid 1px var(--vp-c-divider);
}
.vp-collapse-item:first-child {
border-top: none;
}
.vp-collapse-header {
display: flex;
gap: 6px;
align-items: center;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.vp-collapse-header .vpi-chevron-right {
align-self: baseline;
width: 20px;
height: 20px;
transition: transform var(--vp-t-color);
transform: rotate(0deg);
}
.vp-collapse-item.expand .vpi-chevron-right {
transform: rotate(90deg);
}
.vp-collapse-header .vp-collapse-title {
flex: 1 2;
margin: 0;
line-height: 1;
}
.vp-collapse-content-inner {
padding-top: 12px;
padding-left: 24px;
}
.vp-collapse-content-inner > *:first-child {
margin-top: 0;
}
.vp-collapse-content-inner > *:last-child {
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,154 @@
<script setup lang="ts">
import { Transition, TransitionGroup } from 'vue'
const props = defineProps<{
group?: boolean
appear?: boolean
mode?: 'in-out' | 'out-in' | 'default'
onLeave?: () => void
onAfterLeave?: () => void
onAfterEnter?: () => void
width?: boolean
}>()
function handleBeforeLeave(el: HTMLElement): void {
if (props.width) {
el.style.maxWidth = `${el.offsetWidth}px`
}
else {
el.style.maxHeight = `${el.offsetHeight}px`
}
void el.offsetWidth
}
function handleLeave(el: HTMLElement): void {
if (props.width) {
el.style.maxWidth = '0'
}
else {
el.style.maxHeight = '0'
}
void el.offsetWidth
props.onLeave?.()
}
function handleAfterLeave(el: HTMLElement): void {
if (props.width) {
el.style.maxWidth = ''
}
else {
el.style.maxHeight = ''
}
props.onAfterLeave?.()
}
function handleEnter(el: HTMLElement): void {
el.style.transition = 'none'
if (props.width) {
const memorizedWidth = el.offsetWidth
el.style.maxWidth = '0'
void el.offsetWidth
el.style.transition = ''
el.style.maxWidth = `${memorizedWidth}px`
}
else {
const memorizedHeight = el.offsetHeight
el.style.maxHeight = '0'
void el.offsetWidth
el.style.transition = ''
el.style.maxHeight = `${memorizedHeight}px`
}
void el.offsetWidth
}
function handleAfterEnter(el: HTMLElement): void {
if (props.width) {
el.style.maxWidth = ''
}
else {
el.style.maxHeight = ''
}
props.onAfterEnter?.()
}
</script>
<template>
<component
:is="group ? TransitionGroup : Transition"
:name="width ? 'fade-in-width-expand' : 'fade-in-height-expand'"
:mode
:appear
@enter="handleEnter"
@after-enter="handleAfterEnter"
@before-leave="handleBeforeLeave"
@leave="handleLeave"
@after-leave="handleAfterLeave"
>
<slot />
</component>
</template>
<style>
.fade-in-height-expand-leave-from,
.fade-in-height-expand-enter-to,
.fade-in-width-expand-leave-from,
.fade-in-width-expand-enter-to {
opacity: 1;
}
.fade-in-height-expand-leave-to,
.fade-in-height-expand-enter-from {
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
opacity: 0;
}
.fade-in-height-expand-leave-active {
overflow: hidden;
transition:
max-height cubic-bezier(0.4, 0, 0.2, 1) 0.3s,
opacity cubic-bezier(0, 0, 0.2, 1) 0.3s,
margin-top cubic-bezier(0.4, 0, 0.2, 1) 0.3s,
margin-bottom cubic-bezier(0.4, 0, 0.2, 1) 0.3s,
padding-top cubic-bezier(0.4, 0, 0.2, 1) 0.3s,
padding-bottom cubic-bezier(0.4, 0, 0.2, 1) 0.3s;
}
.fade-in-height-expand-enter-active {
overflow: hidden;
transition:
max-height cubic-bezier(0.4, 0, 0.2, 1) 0.3s,
opacity cubic-bezier(0.4, 0, 1, 1) 0.3s,
margin-top cubic-bezier(0.4, 0, 0.2, 1) 0.3s,
margin-bottom cubic-bezier(0.4, 0, 0.2, 1) 0.3s,
padding-top cubic-bezier(0.4, 0, 0.2, 1) 0.3s,
padding-bottom cubic-bezier(0.4, 0, 0.2, 1) 0.3s;
}
.fade-in-width-expand-leave-to,
.fade-in-width-expand-enter-from {
margin-right: 0 !important;
margin-left: 0 !important;
opacity: 0 !important;
}
.fade-in-width-expand-leave-active {
overflow: hidden;
transition:
max-width cubic-bezier(0.4, 0, 0.2, 1) 0.2s 0.1s,
opacity cubic-bezier(0.4, 0, 0.2, 1) 0.2s,
margin-right cubic-bezier(0.4, 0, 0.2, 1) 0.2s 0.1s,
margin-left cubic-bezier(0.4, 0, 0.2, 1) 0.2s 0.1s;
}
.fade-in-width-expand-enter-active {
overflow: hidden;
transition:
max-width cubic-bezier(0.4, 0, 0.2, 1) 0.2s,
opacity cubic-bezier(0.4, 0, 0.2, 1) 0.2s 0.1s,
margin-right cubic-bezier(0.4, 0, 0.2, 1) 0.2s,
margin-left cubic-bezier(0.4, 0, 0.2, 1) 0.2s;
}
</style>

View File

@ -30,3 +30,7 @@ if (installed.mpegtsjs) {
export const INJECT_TIMELINE_KEY = Symbol(
__VUEPRESS_DEV__ ? 'timeline' : '',
)
export const INJECT_COLLAPSE_KEY = Symbol(
__VUEPRESS_DEV__ ? 'collapse' : '',
)

View File

@ -0,0 +1,119 @@
/**
* ::: collapse accordion
* - +
*
* - -
*
* :::
*/
import type Token from 'markdown-it/lib/token.mjs'
import type { Markdown } from 'vuepress/markdown'
import { resolveAttrs } from '.././utils/resolveAttrs.js'
import { createContainerPlugin } from './createContainer.js'
interface CollapseMeta {
accordion?: boolean
expand?: boolean
}
interface CollapseItemMeta {
expand?: boolean
index?: number
}
export function collapsePlugin(md: Markdown): void {
createContainerPlugin(md, 'collapse', {
before: (info, tokens, index) => {
const { attrs } = resolveAttrs<CollapseMeta>(info)
const idx = parseCollapse(tokens, index, attrs)
const { accordion } = attrs
return `<VPCollapse${accordion ? ' accordion' : ''}${idx !== undefined ? ` :index="${idx}"` : ''}>`
},
after: () => `</VPCollapse>`,
})
md.renderer.rules.collapse_item_open = (tokens, idx) => {
const token = tokens[idx]
const { expand, index } = token.meta as CollapseItemMeta
return `<VPCollapseItem${expand ? ' expand' : ''}${` :index="${index}"`}>`
}
md.renderer.rules.collapse_item_close = () => '</VPCollapseItem>'
md.renderer.rules.collapse_item_title_open = () => '<template #title>'
md.renderer.rules.collapse_item_title_close = () => '</template>'
}
function parseCollapse(tokens: Token[], index: number, attrs: CollapseMeta): number | void {
const listStack: number[] = [] // 记录列表嵌套深度
let idx = -1 // 记录当前列表项下标
let defaultIndex: number | undefined
let hashExpand = false
for (let i = index + 1; i < tokens.length; i++) {
const token = tokens[i]
if (token.type === 'container_collapse_close') {
break
}
// 列表层级追踪
if (token.type === 'bullet_list_open') {
listStack.push(0) // 每个新列表初始层级为0
if (listStack.length === 1)
token.hidden = true
}
else if (token.type === 'bullet_list_close') {
listStack.pop()
if (listStack.length === 0)
token.hidden = true
}
else if (token.type === 'list_item_open') {
const currentLevel = listStack.length
// 仅处理根级列表项层级1
if (currentLevel === 1) {
token.type = 'collapse_item_open'
tokens[i + 1].type = 'collapse_item_title_open'
tokens[i + 3].type = 'collapse_item_title_close'
idx++
const inlineToken = tokens[i + 2]
const firstToken = inlineToken.children![0]
let flag: string = ''
let expand: boolean | undefined
if (firstToken.type === 'text') {
firstToken.content = firstToken.content.trim().replace(/^:[+\-]\s*/, (match) => {
flag = match.trim()
return ''
})
}
if (attrs.accordion) {
if (!hashExpand && flag === ':+') {
expand = hashExpand = true
defaultIndex = idx
}
}
else if (flag === ':+') {
expand = true
}
else if (flag === ':-') {
expand = false
}
else {
expand = !!attrs.expand
}
token.meta = {
index: idx,
expand,
} as CollapseItemMeta
}
}
else if (token.type === 'list_item_close') {
const currentLevel = listStack.length
if (currentLevel === 1) {
token.type = 'collapse_item_close'
}
}
}
if (attrs.accordion && attrs.expand && !hashExpand) {
defaultIndex = 0
}
return defaultIndex
}

View File

@ -1,21 +1,27 @@
import type Token from 'markdown-it/lib/token.mjs'
import type { RenderRule } from 'markdown-it/lib/renderer.mjs'
import type { Markdown } from 'vuepress/markdown'
import container from 'markdown-it-container'
type RenderRuleParams = Parameters<RenderRule> extends [...infer Args, infer _] ? Args : never
export interface ContainerOptions {
before?: (info: string, tokens: Token[], idx: number) => string
after?: (info: string, tokens: Token[], idx: number) => string
before?: (info: string, ...args: RenderRuleParams) => string
after?: (info: string, ...args: RenderRuleParams) => string
}
export function createContainerPlugin(md: Markdown, type: string, options: ContainerOptions = {}) {
const render = (tokens: Token[], index: number): string => {
export function createContainerPlugin(
md: Markdown,
type: string,
{ before, after }: ContainerOptions = {},
) {
const render: RenderRule = (tokens, index, options, env): string => {
const token = tokens[index]
const info = token.info.trim().slice(type.length).trim() || ''
if (token.nesting === 1) {
return options.before?.(info, tokens, index) || `<div class="custom-container ${type}">`
return before?.(info, tokens, index, options, env) || `<div class="custom-container ${type}">`
}
else {
return options.after?.(info, tokens, index) || '</div>'
return after?.(info, tokens, index, options, env) || '</div>'
}
}

View File

@ -5,6 +5,7 @@ import { isPlainObject } from '@vuepress/helper'
import { alignPlugin } from './align.js'
import { cardPlugin } from './card.js'
import { codeTabs } from './codeTabs.js'
import { collapsePlugin } from './collapse.js'
import { demoWrapperPlugin } from './demoWrapper.js'
import { fileTreePlugin } from './fileTree.js'
import { langReplPlugin } from './langRepl.js'
@ -50,4 +51,7 @@ export async function containerPlugin(
if (options.timeline)
timelinePlugin(md)
if (options.collapse)
collapsePlugin(md)
}

View File

@ -109,6 +109,13 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
enhances.add(`app.component('VPTimelineItem', VPTimelineItem)`)
}
if (options.collapse) {
imports.add(`import VPCollapse from '${CLIENT_FOLDER}components/VPCollapse.vue'`)
imports.add(`import VPCollapseItem from '${CLIENT_FOLDER}components/VPCollapseItem.vue'`)
enhances.add(`app.component('VPCollapse', VPCollapse)`)
enhances.add(`app.component('VPCollapseItem', VPCollapseItem)`)
}
return app.writeTemp(
'md-power/config.js',
`\

View File

@ -61,7 +61,9 @@ export interface MarkdownPowerPluginOptions {
*
* ```md
* ::: timeline
* - title time="Q1" icon="ri:clockwise-line" line="dashed" type="warning" color="red"
* - title
* time="Q1" icon="ri:clockwise-line" line="dashed" type="warning" color="red"
*
* xxx
* :::
* ```
@ -70,6 +72,25 @@ export interface MarkdownPowerPluginOptions {
*/
timeline?: boolean
/**
* collapse
*
* ```md
* ::: collapse accordion
* - + title
*
* content
*
* - - title
*
* content
* :::
* ```
*
* @default false
*/
collapse?: boolean
// video embed
/**
* bilibili

View File

@ -58,6 +58,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [
'repl',
'replit',
'timeline',
'collapse',
'youtube',
]