From cca923a2351f21b32e4edecbf9ace6c6939eeb43 Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Sun, 23 Mar 2025 18:29:45 +0800 Subject: [PATCH] feat(plugin-md-power): add `collapse` syntax support (#535) --- docs/.vuepress/notes/zh/theme-guide.ts | 1 + docs/.vuepress/theme.ts | 1 + docs/notes/theme/config/markdown.md | 6 + docs/notes/theme/guide/markdown/collapse.md | 269 ++++++++++++++++++ .../__snapshots__/collapse.spec.ts.snap | 32 +++ .../plugin-md-power/__test__/collapse.spec.ts | 93 ++++++ .../src/client/components/VPCollapse.vue | 31 ++ .../src/client/components/VPCollapseItem.vue | 118 ++++++++ .../components/VPFadeInExpandTransition.vue | 154 ++++++++++ plugins/plugin-md-power/src/client/options.ts | 4 + .../src/node/container/collapse.ts | 119 ++++++++ .../src/node/container/createContainer.ts | 20 +- .../src/node/container/index.ts | 4 + .../src/node/prepareConfigFile.ts | 7 + plugins/plugin-md-power/src/shared/plugin.ts | 23 +- theme/src/node/detector/fields.ts | 1 + 16 files changed, 875 insertions(+), 8 deletions(-) create mode 100644 docs/notes/theme/guide/markdown/collapse.md create mode 100644 plugins/plugin-md-power/__test__/__snapshots__/collapse.spec.ts.snap create mode 100644 plugins/plugin-md-power/__test__/collapse.spec.ts create mode 100644 plugins/plugin-md-power/src/client/components/VPCollapse.vue create mode 100644 plugins/plugin-md-power/src/client/components/VPCollapseItem.vue create mode 100644 plugins/plugin-md-power/src/client/components/VPFadeInExpandTransition.vue create mode 100644 plugins/plugin-md-power/src/node/container/collapse.ts diff --git a/docs/.vuepress/notes/zh/theme-guide.ts b/docs/.vuepress/notes/zh/theme-guide.ts index 64786f5d..09a96462 100644 --- a/docs/.vuepress/notes/zh/theme-guide.ts +++ b/docs/.vuepress/notes/zh/theme-guide.ts @@ -45,6 +45,7 @@ export const themeGuide = defineNoteConfig({ 'tabs', 'timeline', 'demo-wrapper', + 'collapse', 'npm-to', 'caniuse', 'include', diff --git a/docs/.vuepress/theme.ts b/docs/.vuepress/theme.ts index 2095369b..1a7e0e52 100644 --- a/docs/.vuepress/theme.ts +++ b/docs/.vuepress/theme.ts @@ -29,6 +29,7 @@ export const theme: Theme = plumeTheme({ annotation: true, abbr: true, timeline: true, + collapse: true, imageSize: 'all', pdf: true, caniuse: true, diff --git a/docs/notes/theme/config/markdown.md b/docs/notes/theme/config/markdown.md index ce9b002a..45babe5e 100644 --- a/docs/notes/theme/config/markdown.md +++ b/docs/notes/theme/config/markdown.md @@ -161,6 +161,12 @@ export default defineUserConfig({ - **默认值**: `false` - **详情**: 是否启用时间线容器语法 +### collapse + +- **类型**: `boolean` +- **默认值**: `false` +- **详情**: 是否启用折叠面板容器语法 + ### demo - **类型**: `boolean` diff --git a/docs/notes/theme/guide/markdown/collapse.md b/docs/notes/theme/guide/markdown/collapse.md new file mode 100644 index 00000000..7d85d057 --- /dev/null +++ b/docs/notes/theme/guide/markdown/collapse.md @@ -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 + + 正文内容 +::: diff --git a/plugins/plugin-md-power/__test__/__snapshots__/collapse.spec.ts.snap b/plugins/plugin-md-power/__test__/__snapshots__/collapse.spec.ts.snap new file mode 100644 index 00000000..32f80d86 --- /dev/null +++ b/plugins/plugin-md-power/__test__/__snapshots__/collapse.spec.ts.snap @@ -0,0 +1,32 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`collapsePlugin > should work 1`] = ` +"

内容

+

内容

+
    +
  • 列表 1
  • +
  • 列表 2
  • +
+

内容

+
" +`; + +exports[`collapsePlugin > should work with accordion 1`] = ` +"

内容

+

内容

+

内容

+

内容

+

内容

+

内容

+

内容

+

内容

+

内容

+
" +`; + +exports[`collapsePlugin > should work with expand 1`] = ` +"

内容

+

内容

+

内容

+
" +`; diff --git a/plugins/plugin-md-power/__test__/collapse.spec.ts b/plugins/plugin-md-power/__test__/collapse.spec.ts new file mode 100644 index 00000000..a505f35c --- /dev/null +++ b/plugins/plugin-md-power/__test__/collapse.spec.ts @@ -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() + }) +}) diff --git a/plugins/plugin-md-power/src/client/components/VPCollapse.vue b/plugins/plugin-md-power/src/client/components/VPCollapse.vue new file mode 100644 index 00000000..ab7b2528 --- /dev/null +++ b/plugins/plugin-md-power/src/client/components/VPCollapse.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/plugins/plugin-md-power/src/client/components/VPCollapseItem.vue b/plugins/plugin-md-power/src/client/components/VPCollapseItem.vue new file mode 100644 index 00000000..b08eabda --- /dev/null +++ b/plugins/plugin-md-power/src/client/components/VPCollapseItem.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/plugins/plugin-md-power/src/client/components/VPFadeInExpandTransition.vue b/plugins/plugin-md-power/src/client/components/VPFadeInExpandTransition.vue new file mode 100644 index 00000000..78b55107 --- /dev/null +++ b/plugins/plugin-md-power/src/client/components/VPFadeInExpandTransition.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/plugins/plugin-md-power/src/client/options.ts b/plugins/plugin-md-power/src/client/options.ts index 0d6f8bda..7b135abd 100644 --- a/plugins/plugin-md-power/src/client/options.ts +++ b/plugins/plugin-md-power/src/client/options.ts @@ -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' : '', +) diff --git a/plugins/plugin-md-power/src/node/container/collapse.ts b/plugins/plugin-md-power/src/node/container/collapse.ts new file mode 100644 index 00000000..9a447cb3 --- /dev/null +++ b/plugins/plugin-md-power/src/node/container/collapse.ts @@ -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(info) + const idx = parseCollapse(tokens, index, attrs) + const { accordion } = attrs + + return `` + }, + after: () => ``, + }) + md.renderer.rules.collapse_item_open = (tokens, idx) => { + const token = tokens[idx] + const { expand, index } = token.meta as CollapseItemMeta + return `` + } + md.renderer.rules.collapse_item_close = () => '' + md.renderer.rules.collapse_item_title_open = () => '' +} + +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 +} diff --git a/plugins/plugin-md-power/src/node/container/createContainer.ts b/plugins/plugin-md-power/src/node/container/createContainer.ts index 9c9e251f..2d883d50 100644 --- a/plugins/plugin-md-power/src/node/container/createContainer.ts +++ b/plugins/plugin-md-power/src/node/container/createContainer.ts @@ -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 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) || `
` + return before?.(info, tokens, index, options, env) || `
` } else { - return options.after?.(info, tokens, index) || '
' + return after?.(info, tokens, index, options, env) || '
' } } diff --git a/plugins/plugin-md-power/src/node/container/index.ts b/plugins/plugin-md-power/src/node/container/index.ts index 999a8b1b..fb9b8af3 100644 --- a/plugins/plugin-md-power/src/node/container/index.ts +++ b/plugins/plugin-md-power/src/node/container/index.ts @@ -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) } diff --git a/plugins/plugin-md-power/src/node/prepareConfigFile.ts b/plugins/plugin-md-power/src/node/prepareConfigFile.ts index 38b6a08d..fa8c0987 100644 --- a/plugins/plugin-md-power/src/node/prepareConfigFile.ts +++ b/plugins/plugin-md-power/src/node/prepareConfigFile.ts @@ -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', `\ diff --git a/plugins/plugin-md-power/src/shared/plugin.ts b/plugins/plugin-md-power/src/shared/plugin.ts index 4e50073c..ed0cda20 100644 --- a/plugins/plugin-md-power/src/shared/plugin.ts +++ b/plugins/plugin-md-power/src/shared/plugin.ts @@ -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 视频嵌入 diff --git a/theme/src/node/detector/fields.ts b/theme/src/node/detector/fields.ts index c246477f..ac33c761 100644 --- a/theme/src/node/detector/fields.ts +++ b/theme/src/node/detector/fields.ts @@ -58,6 +58,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [ 'repl', 'replit', 'timeline', + 'collapse', 'youtube', ]