From 81fcd858104f12e3a9b6bd65e3d565332d0ac88a Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Sat, 4 Jun 2022 15:53:39 +0800 Subject: [PATCH] feat(md-enhance): add CodeTabs and Tabs --- .gitignore | 4 + .../__tests__/demo/.vuepress/config.js | 3 + .../unit/__snapshots__/codeTabs.spec.ts.snap | 249 +++++++++++++ .../unit/__snapshots__/tabs.spec.ts.snap | 163 +++++++++ .../__tests__/unit/codeTabs.spec.ts | 332 ++++++++++++++++++ .../md-enhance/__tests__/unit/tabs.spec.ts | 223 ++++++++++++ .../src/client/components/CodeTabs.ts | 149 ++++++++ .../md-enhance/src/client/components/Tabs.ts | 146 ++++++++ .../md-enhance/src/client/enhanceAppFile.ts | 6 +- .../src/client/styles/code-tabs.styl | 93 +++++ .../md-enhance/src/client/styles/tabs.styl | 112 ++++++ .../src/node/markdown-it/codeTabs.ts | 47 +++ .../md-enhance/src/node/markdown-it/index.ts | 4 +- .../md-enhance/src/node/markdown-it/tabs.ts | 322 +++++++++++++++++ packages/md-enhance/src/node/plugin.ts | 12 + packages/md-enhance/src/types/declare.d.ts | 12 + packages/md-enhance/src/types/options.d.ts | 32 +- 17 files changed, 1889 insertions(+), 20 deletions(-) create mode 100644 packages/md-enhance/__tests__/unit/__snapshots__/codeTabs.spec.ts.snap create mode 100644 packages/md-enhance/__tests__/unit/__snapshots__/tabs.spec.ts.snap create mode 100644 packages/md-enhance/__tests__/unit/codeTabs.spec.ts create mode 100644 packages/md-enhance/__tests__/unit/tabs.spec.ts create mode 100644 packages/md-enhance/src/client/components/CodeTabs.ts create mode 100644 packages/md-enhance/src/client/components/Tabs.ts create mode 100644 packages/md-enhance/src/client/styles/code-tabs.styl create mode 100644 packages/md-enhance/src/client/styles/tabs.styl create mode 100644 packages/md-enhance/src/node/markdown-it/codeTabs.ts create mode 100644 packages/md-enhance/src/node/markdown-it/tabs.ts diff --git a/.gitignore b/.gitignore index ff20bf88b..bdcd55739 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ packages/theme/**/*d.ts # Coverage Files coverage/ + +# Cache and Temp files +**/.vuepress/.cache/ +**/.vuepress/.temp/ diff --git a/packages/md-enhance/__tests__/demo/.vuepress/config.js b/packages/md-enhance/__tests__/demo/.vuepress/config.js index b600f0621..38ee74d4f 100644 --- a/packages/md-enhance/__tests__/demo/.vuepress/config.js +++ b/packages/md-enhance/__tests__/demo/.vuepress/config.js @@ -26,6 +26,9 @@ module.exports = { ], ], + cache: `${__dirname}/.cache`, + temp: `${__dirname}/.temp`, + /** 构建文件输出目录 */ dest: "./dist", diff --git a/packages/md-enhance/__tests__/unit/__snapshots__/codeTabs.spec.ts.snap b/packages/md-enhance/__tests__/unit/__snapshots__/codeTabs.spec.ts.snap new file mode 100644 index 000000000..0d24f2610 --- /dev/null +++ b/packages/md-enhance/__tests__/unit/__snapshots__/codeTabs.spec.ts.snap @@ -0,0 +1,249 @@ +// Vitest Snapshot v1 + +exports[`code tabs > shoud render mutiple block 1`] = ` +" + + + + +" +`; + +exports[`code tabs > shoud render mutiple block 2`] = ` +" + + + + +" +`; + +exports[`code tabs > shoud render single block 1`] = ` +" + + + +" +`; + +exports[`code tabs > shoud render single block 2`] = ` +" + + + +" +`; + +exports[`code tabs > shoud support active 1`] = ` +" + + + +" +`; + +exports[`code tabs > shoud support active 2`] = ` +" + + + +" +`; + +exports[`code tabs > shoud support active 3`] = ` +" + + + + +" +`; + +exports[`code tabs > shoud support active 4`] = ` +" + + + + +" +`; + +exports[`code tabs > shoud support id 1`] = ` +" + + + +" +`; + +exports[`code tabs > shoud support id 2`] = ` +" + + + +" +`; + +exports[`code tabs > shoud support id 3`] = ` +" + + + +" +`; + +exports[`code tabs > shoud support id 4`] = ` +" + + + +" +`; + +exports[`code tabs > shoud support value 1`] = ` +" + + + +" +`; + +exports[`code tabs > shoud support value 2`] = ` +" + + + +" +`; + +exports[`code tabs > shoud support value 3`] = ` +" + + + + +" +`; + +exports[`code tabs > shoud support value 4`] = ` +" + + + + +" +`; + +exports[`code tabs > should ignore other items 1`] = ` +" + + + +" +`; + +exports[`code tabs > should ignore other items 2`] = ` +" + + + +" +`; + +exports[`code tabs > should ignore other items 3`] = ` +" + + + + +" +`; + +exports[`code tabs > should ignore other items 4`] = ` +" + + + + +" +`; diff --git a/packages/md-enhance/__tests__/unit/__snapshots__/tabs.spec.ts.snap b/packages/md-enhance/__tests__/unit/__snapshots__/tabs.spec.ts.snap new file mode 100644 index 000000000..c51b2e74a --- /dev/null +++ b/packages/md-enhance/__tests__/unit/__snapshots__/tabs.spec.ts.snap @@ -0,0 +1,163 @@ +// Vitest Snapshot v1 + +exports[`tabs > shoud render mutiple block 1`] = ` +" + + + +" +`; + +exports[`tabs > shoud render mutiple block 2`] = ` +" + + + +" +`; + +exports[`tabs > shoud render single block 1`] = ` +" + + +" +`; + +exports[`tabs > shoud render single block 2`] = ` +" + + +" +`; + +exports[`tabs > shoud support active 1`] = ` +" + + +" +`; + +exports[`tabs > shoud support active 2`] = ` +" + + +" +`; + +exports[`tabs > shoud support active 3`] = ` +" + + + +" +`; + +exports[`tabs > shoud support active 4`] = ` +" + + + +" +`; + +exports[`tabs > shoud support tabs id 1`] = ` +" + + +" +`; + +exports[`tabs > shoud support tabs id 2`] = ` +" + + +" +`; + +exports[`tabs > shoud support tabs id 3`] = ` +" + + +" +`; + +exports[`tabs > shoud support tabs id 4`] = ` +" + + +" +`; + +exports[`tabs > should ignore other items 1`] = ` +" + + + +" +`; + +exports[`tabs > should ignore other items 2`] = ` +" + + + +" +`; diff --git a/packages/md-enhance/__tests__/unit/codeTabs.spec.ts b/packages/md-enhance/__tests__/unit/codeTabs.spec.ts new file mode 100644 index 000000000..aaa9ca42b --- /dev/null +++ b/packages/md-enhance/__tests__/unit/codeTabs.spec.ts @@ -0,0 +1,332 @@ +import { describe, it, expect } from "vitest"; +import MarkdownIt from "markdown-it"; +import { codeTabs } from "../../src/node/markdown-it/codeTabs"; + +const markdownIt = MarkdownIt({ linkify: true }).use(codeTabs); + +describe("code tabs", () => { + it("shoud render single block", () => { + expect( + markdownIt.render(` +::: code-tabs + +@tab js + +\`\`\`js +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs +@tab js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + }); + + it("shoud render mutiple block", () => { + expect( + markdownIt.render(` +::: code-tabs + +@tab js + +\`\`\`js +const a = 1; +\`\`\` + +@tab ts + +\`\`\`ts +const a = 1; +\`\`\` + +::: +`) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs +@tab js +\`\`\`js +const a = 1; +\`\`\` +@tab ts +\`\`\`ts +const a = 1; +\`\`\` +::: +`) + ).toMatchSnapshot(); + }); + + it("shoud support id", () => { + expect( + markdownIt.render(` +::: code-tabs#event + +@tab js + +\`\`\`js +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs#event-id +@tab js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs#id with space +@tab js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs # id starts and having space in the end +@tab js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + }); + + it("shoud support active", () => { + expect( + markdownIt.render(` +::: code-tabs + +@tab:active js + +\`\`\`js +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs +@tab:active js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs + +@tab js + +\`\`\`js +const a = 1; +\`\`\` + +@tab:active ts + +\`\`\`ts +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs +@tab js +\`\`\`js +const a = 1; +\`\`\` +@tab:active ts +\`\`\`ts +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + }); + + it("shoud support value", () => { + expect( + markdownIt.render(` +::: code-tabs + +@tab js#javascript + +\`\`\`js +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs +@tab:active js#javascript +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs + +@tab js#js + +\`\`\`js +const a = 1; +\`\`\` + +@tab:active ts #typescript + +\`\`\`ts +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs +@tab js # javascript +\`\`\`js +const a = 1; +\`\`\` +@tab:active ts #typescript +\`\`\`ts +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + }); + + it("should ignore other items", () => { + expect( + markdownIt.render(` +::: code-tabs + +\`\`\`coffee +const a = 1; +\`\`\` + +@tab:active js + +\`\`\`js +const a = 1; +\`\`\` + +\`\`\`ts +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs +\`\`\`coffee +const a = 1; +\`\`\` +@tab:active js +\`\`\`js +const a = 1; +\`\`\` +\`\`\`ts +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs + +@tab js + +A text + +\`\`\`js +const a = 1; +\`\`\` + +Another text + +@tab:active ts + +Another text again + +\`\`\`ts +const a = 1; +\`\`\` + +Another text again + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: code-tabs +@tab js +A text +\`\`\`js +const a = 1; +\`\`\` +Another text +@tab:active ts +Another text again +\`\`\`ts +const a = 1; +\`\`\` +Another text again +::: + `) + ).toMatchSnapshot(); + }); +}); diff --git a/packages/md-enhance/__tests__/unit/tabs.spec.ts b/packages/md-enhance/__tests__/unit/tabs.spec.ts new file mode 100644 index 000000000..5406e71ec --- /dev/null +++ b/packages/md-enhance/__tests__/unit/tabs.spec.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from "vitest"; +import MarkdownIt from "markdown-it"; +import { tabs } from "../../src/node/markdown-it/tabs"; + +const markdownIt = MarkdownIt({ linkify: true }).use(tabs); + +describe("tabs", () => { + it("shoud render single block", () => { + expect( + markdownIt.render(` +::: tabs + +@tab js + +\`\`\`js +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: tabs +@tab js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + }); + + it("shoud render mutiple block", () => { + expect( + markdownIt.render(` +::: tabs + +@tab js + +\`\`\`js +const a = 1; +\`\`\` + +@tab ts + +\`\`\`ts +const a = 1; +\`\`\` + +::: +`) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: tabs +@tab js +\`\`\`js +const a = 1; +\`\`\` +@tab ts +\`\`\`ts +const a = 1; +\`\`\` +::: +`) + ).toMatchSnapshot(); + }); + + it("shoud support tabs id", () => { + expect( + markdownIt.render(` +::: tabs#event + +@tab js + +\`\`\`js +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: tabs#event-id +@tab js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: tabs#id with space +@tab js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: tabs # id starts and having space in the end +@tab js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + }); + + it("shoud support active", () => { + expect( + markdownIt.render(` +::: tabs + +@tab:active js + +\`\`\`js +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: tabs +@tab:active js +\`\`\`js +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: tabs + +@tab js + +\`\`\`js +const a = 1; +\`\`\` + +@tab:active ts + +\`\`\`ts +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: tabs +@tab js +\`\`\`js +const a = 1; +\`\`\` +@tab:active ts +\`\`\`ts +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + }); + + it("should ignore other items", () => { + expect( + markdownIt.render(` +::: tabs + +\`\`\`coffee +const a = 1; +\`\`\` + +@tab:active js + +\`\`\`js +const a = 1; +\`\`\` + +\`\`\`ts +const a = 1; +\`\`\` + +::: + `) + ).toMatchSnapshot(); + + expect( + markdownIt.render(` +::: tabs +\`\`\`coffee +const a = 1; +\`\`\` +@tab:active js +\`\`\`js +const a = 1; +\`\`\` +\`\`\`ts +const a = 1; +\`\`\` +::: + `) + ).toMatchSnapshot(); + }); +}); diff --git a/packages/md-enhance/src/client/components/CodeTabs.ts b/packages/md-enhance/src/client/components/CodeTabs.ts new file mode 100644 index 000000000..f737aebb2 --- /dev/null +++ b/packages/md-enhance/src/client/components/CodeTabs.ts @@ -0,0 +1,149 @@ +import Vue, { defineComponent, h, ref, watch } from "vue"; + +import type { Component, PropType, VNode } from "vue"; +import type { TabProps } from "./Tabs"; + +import "../styles/code-tabs.styl"; + +const codeTabStore = ref>({}); + +export default defineComponent({ + name: "CodeTabs", + + props: { + active: { type: Number, default: 0 }, + data: { + type: Array as PropType, + required: true, + }, + tabId: { + type: String, + default: "", + }, + }, + + setup(props, { slots }) { + const getInitialIndex = (): number => { + if (props.tabId) { + const valueIndex = props.data!.findIndex( + ({ title, value = title }) => + codeTabStore.value[props.tabId] === value + ); + + if (valueIndex !== -1) return valueIndex; + } + + return props.active; + }; + + // index of current active item + const activeIndex = ref(getInitialIndex()); + + // refs of the tab buttons + const tabRefs = ref([]); + + // update store + const updateStore = (): void => { + if (props.tabId) { + const { title, value = title } = props.data![activeIndex.value]; + + codeTabStore.value[props.tabId] = value; + } + }; + + // activate next tab + const activateNext = (index = activeIndex.value): void => { + activeIndex.value = index < tabRefs.value.length - 1 ? index + 1 : 0; + tabRefs.value[activeIndex.value].focus(); + }; + + // activate previous tab + const activatePrev = (index = activeIndex.value): void => { + activeIndex.value = index > 0 ? index - 1 : tabRefs.value.length - 1; + tabRefs.value[activeIndex.value].focus(); + }; + + // handle keyboard event + const keyboardHandler = (event: KeyboardEvent, index: number): void => { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + activeIndex.value = index; + } else if (event.key === "ArrowRight") { + event.preventDefault(); + activateNext(); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + activatePrev(); + } + + if (props.tabId) { + const { title, value = title } = props.data![activeIndex.value]; + + codeTabStore.value[props.tabId] = value; + } + }; + + watch( + () => codeTabStore.value[props.tabId], + (newValue, oldValue) => { + if (props.tabId && newValue !== oldValue) { + const index = props.data!.findIndex( + ({ title, value = title }) => value === newValue + ); + + if (index !== -1) activeIndex.value = index; + } + } + ); + + return (): VNode | null => + h(Vue.component("ClientOnly"), [ + props.data!.length + ? h("div", { class: "code-tabs" }, [ + h( + "div", + { class: "code-tabs-nav" }, + props.data!.map(({ title }, index) => { + const isActive = index === activeIndex.value; + + return h( + "button", + { + ref: "tabRefs", + class: ["code-tabs-nav-tab", { active: isActive }], + attrs: { + "aria-pressed": isActive, + "aria-expanded": isActive, + }, + on: { + click: () => { + activeIndex.value = index; + updateStore(); + }, + keydown: (event: KeyboardEvent) => + keyboardHandler(event, index), + }, + }, + title + ); + }) + ), + props.data!.map(({ title, value = title }, index) => { + const isActive = index === activeIndex.value; + + return h( + "div", + { + class: ["code-tab", { active: isActive }], + attrs: { + "aria-selected": isActive, + }, + }, + slots[`tab${index}`]?.({ title, value, isActive }) + ); + }), + ]) + : null, + ]); + }, +}) as Component; diff --git a/packages/md-enhance/src/client/components/Tabs.ts b/packages/md-enhance/src/client/components/Tabs.ts new file mode 100644 index 000000000..3fb3e53ce --- /dev/null +++ b/packages/md-enhance/src/client/components/Tabs.ts @@ -0,0 +1,146 @@ +import Vue, { defineComponent, h, ref, watch } from "vue"; +import type { Component, PropType, VNode } from "vue"; + +import "../styles/tabs.styl"; + +export interface TabProps extends Record { + title: string; + value?: string; +} + +const tabStore = ref>({}); + +export default defineComponent({ + // eslint-disable-next-line vue/multi-word-component-names + name: "Tabs", + + props: { + active: { type: Number, default: 0 }, + data: { + type: Array as PropType, + required: true, + }, + tabId: { + type: String, + default: "", + }, + }, + + setup(props, { slots }) { + const getInitialIndex = (): number => { + if (props.tabId) { + const valueIndex = props.data!.findIndex( + ({ title, value = title }) => tabStore.value[props.tabId] === value + ); + + if (valueIndex !== -1) return valueIndex; + } + + return props.active; + }; + + // index of current active item + const activeIndex = ref(getInitialIndex()); + + // refs of the tab buttons + const tabRefs = ref([]); + + // update store + const updateStore = (): void => { + if (props.tabId) { + const { title, value = title } = props.data![activeIndex.value]; + + tabStore.value[props.tabId] = value; + } + }; + + // activate next tab + const activateNext = (index = activeIndex.value): void => { + activeIndex.value = index < tabRefs.value.length - 1 ? index + 1 : 0; + tabRefs.value[activeIndex.value].focus(); + }; + + // activate previous tab + const activatePrev = (index = activeIndex.value): void => { + activeIndex.value = index > 0 ? index - 1 : tabRefs.value.length - 1; + tabRefs.value[activeIndex.value].focus(); + }; + + // handle keyboard event + const keyboardHandler = (event: KeyboardEvent, index: number): void => { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + activeIndex.value = index; + } else if (event.key === "ArrowRight") { + event.preventDefault(); + activateNext(); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + activatePrev(); + } + + updateStore(); + }; + + watch( + () => tabStore.value[props.tabId], + (newValue, oldValue) => { + if (props.tabId && newValue !== oldValue) { + const index = props.data!.findIndex( + ({ title, value = title }) => value === newValue + ); + + if (index !== -1) activeIndex.value = index; + } + } + ); + + return (): VNode | null => + h(Vue.component("ClientOnly"), [ + h("div", { class: "tab-list" }, [ + h( + "div", + { class: "tab-list-nav" }, + props.data!.map(({ title }, index) => { + const isActive = index === activeIndex.value; + + return h( + "button", + { + ref: "tabRefs", + class: ["tab-list-nav-item", { active: isActive }], + attrs: { + "aria-pressed": isActive, + "aria-expanded": isActive, + }, + on: { + click: () => { + activeIndex.value = index; + updateStore(); + }, + keydown: (event: KeyboardEvent) => + keyboardHandler(event, index), + }, + }, + title + ); + }) + ), + props.data!.map(({ title, value = title }, index) => { + const isActive = index === activeIndex.value; + + return h( + "div", + { + class: ["tab-item", { active: isActive }], + attrs: { + "aria-selected": isActive, + }, + }, + slots[`tab${index}`]?.({ title, value, isActive }) + ); + }), + ]), + ]); + }, +}) as Component; diff --git a/packages/md-enhance/src/client/enhanceAppFile.ts b/packages/md-enhance/src/client/enhanceAppFile.ts index 85e224543..6f18d562c 100644 --- a/packages/md-enhance/src/client/enhanceAppFile.ts +++ b/packages/md-enhance/src/client/enhanceAppFile.ts @@ -2,10 +2,12 @@ import ChartJS from "@ChartJS"; import CodeDemo from "@CodeDemo"; import CodeGroup from "@CodeGroup"; import CodeGroupItem from "@CodeGroupItem"; +import CodeTabs from "@CodeTabs"; import ECharts from "@ECharts"; import FlowChart from "@FlowChart"; import Mermaid from "@Mermaid"; import Presentation from "@Presentation"; +import Tabs from "@Tabs"; import type { EnhanceApp } from "vuepress-typings"; const enhanceApp: EnhanceApp = ({ Vue }) => { @@ -15,15 +17,15 @@ const enhanceApp: EnhanceApp = ({ Vue }) => { if (CodeDemo.name) Vue.component("CodeDemo", CodeDemo); if (CodeGroup.name) Vue.component("CodeGroup", CodeGroup); if (CodeGroupItem.name) Vue.component("CodeGroupItem", CodeGroupItem); + if (CodeTabs.name) Vue.component("CodeTabs", CodeTabs); if (MARKDOWN_ENHANCE_FOOTNOTE) void import("./styles/footnote.styl"); if (ECharts.name) Vue.component("ECharts", ECharts); if (FlowChart.name) Vue.component("FlowChart", FlowChart); - if (Mermaid.name) Vue.component("Mermaid", Mermaid); - if (Presentation.name) Vue.component("Presentation", Presentation); + if (Tabs.name) Vue.component("Tabs", Tabs); if (MARKDOWN_ENHANCE_TASKLIST) void import("./styles/tasklist.styl"); diff --git a/packages/md-enhance/src/client/styles/code-tabs.styl b/packages/md-enhance/src/client/styles/code-tabs.styl new file mode 100644 index 000000000..19d030003 --- /dev/null +++ b/packages/md-enhance/src/client/styles/code-tabs.styl @@ -0,0 +1,93 @@ +@require '~vuepress-shared/styles/reset'; + +.code-tabs-nav { + display: flex; + margin: 0.85rem 0 -0.85rem; + padding: 0; + border-radius: 6px 6px 0 0; + background-color: var(--code-tabs-nav-bg-color, #3a404c); + list-style: none; + transition: background-color var(--color-transition, 0.3s); + + @media (max-width: $MQMobileNarrow) { + margin-right: -1.5rem; + margin-left: -1.5rem; + border-radius: 0; + } +} + +.code-tabs-nav-tab { + button(); + position: relative; + min-width: 3rem; + margin: 0; + padding: 6px 10px; + border-radius: 6px 6px 0 0; + background-color: transparent; + color: var(--code-tabs-nav-text-color, #eee); + font-weight: 600; + font-size: 0.85em; + line-height: 1.4; + cursor: pointer; + transition: background-color var(--color-transition, 0.3s), color var(--color-transition, 0.3s); + + &:hover { + background-color: var(--code-tabs-nav-hover-color, #434a57); + } + + &::before, &::after { + content: ' '; + position: absolute; + bottom: 0; + z-index: 1; + width: 6px; + height: 6px; + } + + &::before { + right: 100%; + } + + &::after { + left: 100%; + } + + &.active { + background-color: var(--code-bg-color, #282c34); + + &::before { + background: radial-gradient( + 12px at left top, + transparent 50%, + var(--code-bg-color, #282c34) 50% + ); + } + + &::after { + background: radial-gradient( + 12px at right top, + transparent 50%, + var(--code-bg-color, #282c34) 50% + ); + } + } + + &:first-child { + &::before { + display: none; + } + } +} + +.code-tab { + display: none; + + &.active { + display: block; + } + + div[class*='language-'] { + border-top-left-radius: 0; + border-top-right-radius: 0; + } +} diff --git a/packages/md-enhance/src/client/styles/tabs.styl b/packages/md-enhance/src/client/styles/tabs.styl new file mode 100644 index 000000000..0b0b5ca90 --- /dev/null +++ b/packages/md-enhance/src/client/styles/tabs.styl @@ -0,0 +1,112 @@ +@require '~vuepress-shared/styles/reset'; + +:root { + --tab-bg-color: var(--bg-color, #fff); + --tab-nav-text-color: var(--text-color, #2c3e50); + --tab-nav-bg-color: #e0e0e0; + --tab-nav-hover-color: #eee; +} + +.theme-dark { + --tab-nav-bg-color: #34343f; + --tab-nav-hover-color: #2d2d38; +} + +.tab-list { + margin: 1.5rem 0; + border: 2px solid var(--border-color, #eaecef); + border-radius: 8px; + + @media (max-width: $MQMobileNarrow) { + margin-right: -1.5rem; + margin-left: -1.5rem; + border-radius: 0; + } +} + +.tab-list-nav { + display: flex; + margin: 0; + padding: 0; + border-radius: 8px 8px 0 0; + background-color: var(--tab-nav-bg-color); + list-style: none; + transition: background-color var(--color-transition, 0.3s); + + @media (max-width: $MQMobileNarrow) { + border-radius: 0; + } +} + +.tab-list-nav-item { + button(); + position: relative; + min-width: 4rem; + margin: 0; + padding: 0.5em 1em; + border-radius: 8px 8px 0 0; + background-color: transparent; + color: var(--tab-nav-text-color); + font-weight: 600; + font-size: 0.85em; + line-height: 1.75; + cursor: pointer; + transition: background-color var(--color-transition, 0.3s), color var(--color-transition, 0.3s); + + &:hover { + background-color: var(--tab-nav-hover-color); + } + + &::before, &::after { + content: ' '; + position: absolute; + bottom: 0; + z-index: 1; + width: 8px; + height: 8px; + } + + &::before { + right: 100%; + } + + &::after { + left: 100%; + } + + &.active { + background-color: var(--tab-bg-color); + + &::before { + background: radial-gradient( + 16px at left top, + transparent 50%, + var(--tab-bg-color) 50% + ); + } + + &::after { + background: radial-gradient( + 16px at right top, + transparent 50%, + var(--tab-bg-color) 50% + ); + } + } + + &:first-child { + &::before { + display: none; + } + } +} + +.tab-item { + display: none; + padding: 1rem 0.75rem; + background: var(--tab-bg-color); + + &.active { + display: block; + } +} diff --git a/packages/md-enhance/src/node/markdown-it/codeTabs.ts b/packages/md-enhance/src/node/markdown-it/codeTabs.ts new file mode 100644 index 000000000..fa1cd1890 --- /dev/null +++ b/packages/md-enhance/src/node/markdown-it/codeTabs.ts @@ -0,0 +1,47 @@ +import { tabs } from "./tabs"; + +import type { PluginSimple } from "markdown-it"; + +export const codeTabs: PluginSimple = (md) => { + tabs(md, { + name: "code-tabs", + component: "CodeTabs", + getter: (tokens, index) => { + let inCodeTab = false; + let foundFence = false; + const codeTabsData: { content: string }[] = []; + + for (let i = index; i < tokens.length; i++) { + const { block, type } = tokens[i]; + + if (block) { + if (type === "code-tabs_tabs_close") { + break; + } + + if (type === "tab_close") { + inCodeTab = false; + continue; + } + + if (type === "tab_open") { + // found a code tab + inCodeTab = true; + foundFence = false; + continue; + } + + if (inCodeTab && type === "fence" && !foundFence) { + foundFence = true; + continue; + } + + tokens[i].type = "code_tab_empty"; + tokens[i].hidden = true; + } + } + + return codeTabsData; + }, + }); +}; diff --git a/packages/md-enhance/src/node/markdown-it/index.ts b/packages/md-enhance/src/node/markdown-it/index.ts index 4bdf4b7e5..b97671dcd 100644 --- a/packages/md-enhance/src/node/markdown-it/index.ts +++ b/packages/md-enhance/src/node/markdown-it/index.ts @@ -1,7 +1,7 @@ export * from "./align"; export * from "./chart"; export * from "./codeDemo"; -// export * from "./codeTabs"; +export * from "./codeTabs"; export * from "./container"; export * from "./decodeUrl"; export * from "./echarts"; @@ -16,7 +16,7 @@ export * from "./mermaid"; export * from "./presentation"; export * from "./sub"; export * from "./sup"; -// export * from "./tabs"; +export * from "./tabs"; export * from "./tasklist"; export * from "./stylize"; export * from "./uml"; diff --git a/packages/md-enhance/src/node/markdown-it/tabs.ts b/packages/md-enhance/src/node/markdown-it/tabs.ts new file mode 100644 index 000000000..3d8de1afa --- /dev/null +++ b/packages/md-enhance/src/node/markdown-it/tabs.ts @@ -0,0 +1,322 @@ +import type { Options, PluginWithOptions } from "markdown-it"; +import type { RuleBlock } from "markdown-it/lib/parser_block"; +import type { default as Renderer } from "markdown-it/lib/renderer"; +import type { default as Token } from "markdown-it/lib/token"; + +export interface BaseTabData { + title: string; + value?: string; +} + +export interface TabOptions { + name: string; + component: string; + getter: ( + tokens: Token[], + index: number, + options: Options, + env: unknown, + self: Renderer + ) => Record[]; +} + +export const tabs: PluginWithOptions = ( + md, + { name, component, getter } = { + name: "tabs", + component: "Tabs", + getter: () => [], + } +) => { + const CODETAB_MARKER = `@tab`; + + const tabsRule: RuleBlock = (state, startLine, endLine, silent) => { + let start = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + + // Check out the first character quickly, + // this should filter out most of non-containers + if (state.src[start] !== ":") return false; + + let pos = start + 1; + + // Check out the rest of the marker string + while (pos <= max) { + if (state.src[pos] !== ":") break; + pos += 1; + } + + const markerCount = pos - start; + + if (markerCount < 3) return false; + + const markup = state.src.slice(start, pos); + const params = state.src.slice(pos, max); + + const [containerName, id = ""] = params.split("#", 2); + + if (containerName.trim() !== name) return false; + + // Since start is found, we can report success here in validation mode + if (silent) return true; + + // Search for the end of the block + let nextLine = startLine; + let autoClosed = false; + + // Search for the end of the block + while ( + // unclosed block should be autoclosed by end of document. + // also block seems to be autoclosed by end of parent + nextLine < endLine + ) { + nextLine += 1; + start = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + + if (start < max && state.sCount[nextLine] < state.blkIndent) + // non-empty line with negative indent should stop the list: + // - ``` + // test + break; + + if ( + // match start + + state.src[start] === ":" && + // closing fence should be indented less than 4 spaces + state.sCount[nextLine] - state.blkIndent < 4 + ) { + // check rest of marker + for (pos = start + 1; pos <= max; pos++) + if (state.src[pos] !== ":") break; + + // closing code fence must be at least as long as the opening one + if (pos - start >= markerCount) { + // make sure tail has spaces only + pos = state.skipSpaces(pos); + + if (pos >= max) { + // found! + autoClosed = true; + break; + } + } + } + } + + const oldParent = state.parentType; + const oldLineMax = state.lineMax; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + state.parentType = `${name}_tabs`; + + // this will prevent lazy continuations from ever going past our end marker + state.lineMax = nextLine - (autoClosed ? 1 : 0); + + const openToken = state.push(`${name}_tabs_open`, component, 1); + + openToken.markup = markup; + openToken.block = true; + openToken.info = containerName; + openToken.meta = { id: id.trim() }; + openToken.map = [startLine, nextLine - (autoClosed ? 1 : 0)]; + + state.md.block.tokenize( + state, + startLine + 1, + nextLine - (autoClosed ? 1 : 0) + ); + + const closeToken = state.push(`${name}_tabs_close`, component, -1); + + closeToken.markup = state.src.slice(start, pos); + closeToken.block = true; + + state.parentType = oldParent; + state.lineMax = oldLineMax; + state.line = nextLine + (autoClosed ? 1 : 0); + + return true; + }; + + const tabRule: RuleBlock = (state, startLine, endLine, silent) => { + let start = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + + /* + * Check out the first character quickly, + * this should filter out most of non-uml blocks + */ + if (state.src.charAt(start) !== "@") return false; + + let index; + + // Check out the rest of the marker string + for (index = 0; index < CODETAB_MARKER.length; index++) + if (CODETAB_MARKER[index] !== state.src[start + index]) return false; + + const markup = state.src.slice(start, start + index); + const info = state.src.slice(start + index, max); + + // Since start is found, we can report success here in validation mode + if (silent) return true; + + let nextLine = startLine; + let autoClosed = false; + + // Search for the end of the block + while ( + // unclosed block should be autoclosed by end of document. + // also block seems to be autoclosed by end of parent + nextLine < endLine + ) { + nextLine += 1; + start = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + + if (start < max && state.sCount[nextLine] < state.blkIndent) + // non-empty line with negative indent should stop the list: + // - ``` + // test + break; + + if ( + // match start + state.src[start] === "@" && + // marker should not be indented with respect of opening fence + state.sCount[nextLine] <= state.sCount[startLine] + ) { + let openMakerMatched = true; + + for (index = 0; index < CODETAB_MARKER.length; index++) + if (CODETAB_MARKER[index] !== state.src[start + index]) { + openMakerMatched = false; + break; + } + + if (openMakerMatched) { + // found! + autoClosed = true; + nextLine -= 1; + break; + } + } + } + + const oldParent = state.parentType; + const oldLineMax = state.lineMax; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + state.parentType = `tab`; + + // this will prevent lazy continuations from ever going past our end marker + state.lineMax = nextLine; + + const openToken = state.push("tab_open", "template", 1); + + const [title, id] = info.replace(/^:active/, "").split("#", 2); + + openToken.block = true; + openToken.markup = markup; + openToken.info = title.trim(); + openToken.meta = { + active: info.includes(":active"), + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (id) openToken.meta.value = id.trim(); + openToken.map = [startLine, nextLine]; + + state.md.block.tokenize(state, startLine + 1, nextLine); + + const closeToken = state.push("tab_close", "template", -1); + + closeToken.block = true; + closeToken.markup = ""; + + state.parentType = oldParent; + state.lineMax = oldLineMax; + state.line = nextLine + (autoClosed ? 1 : 0); + + return true; + }; + + md.block.ruler.before("fence", `${name}_tabs`, tabsRule, { + alt: ["paragraph", "reference", "blockquote", "list"], + }); + + // WARNING: Here we use an internal variable to make sure tab rule is not registered + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line + if (!md.block.ruler.__rules__.find(({ name }) => name === "tab")) + md.block.ruler.before("fence", "tab", tabRule, { + alt: ["paragraph", "reference", "blockquote", "list"], + }); + + md.renderer.rules[`${name}_tabs_open`] = ( + tokens, + index, + options, + env, + self + ): string => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { meta } = tokens[index]; + const basicData: BaseTabData[] = []; + const customData = getter(tokens, index, options, env, self); + let activeIndex = -1; + let isTabstart = false; + + for (let i = index; i < tokens.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { block, meta, type, info } = tokens[i]; + + if (block) { + if (type === `${name}_tabs_close`) break; + if (type === `${name}_tabs_open`) continue; + + if (type === "tab_open") { + // code tab is active + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (meta.active) activeIndex = basicData.length; + + tokens[i].attrPush([ + `v-slot:tab${basicData.length}`, + "{ title, value, isActive }", + ]); + + isTabstart = true; + basicData.push({ + title: info, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ...(meta.value ? { value: meta.value as string } : {}), + }); + + continue; + } + + if (type === "tab_close") continue; + + if (!isTabstart) { + tokens[i].type = `${name}_tabs_empty`; + tokens[i].hidden = true; + } + } + } + + return `<${component} :data='${ + // single quote will break @vue/compiler-sfc + JSON.stringify( + basicData.map((item, index) => ({ ...item, ...customData[index] })) + ).replace(/'/g, "'") + }'${activeIndex !== -1 ? ` :active="${activeIndex}"` : ""}${ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + meta.id ? ` tab-id="${meta.id as string}"` : "" + }>\n`; + }; + + md.renderer.rules[`${name}_tabs_close`] = (): string => `\n`; +}; diff --git a/packages/md-enhance/src/node/plugin.ts b/packages/md-enhance/src/node/plugin.ts index 34e6122e3..b4e34099d 100644 --- a/packages/md-enhance/src/node/plugin.ts +++ b/packages/md-enhance/src/node/plugin.ts @@ -5,6 +5,7 @@ import lineNumbers = require("@vuepress/markdown/lib/lineNumbers"); import { CODE_DEMO_DEFAULT_SETTING, chart, + codeTabs, decodeURL, echarts, flowchart, @@ -21,6 +22,7 @@ import { stylize, sub, sup, + tabs, tasklist, vueDemo, legacyCodeDemo, @@ -45,6 +47,7 @@ export const mdEnhancePlugin: Plugin = ( const chartEnable = getStatus("chart"); const containerEnable = getStatus("container"); + const codeTabsEnable = getStatus("codetabs"); const codegroupEnable = getStatus("codegroup"); const demoEnable = getStatus("demo"); const echartsEnable = getStatus("echarts"); @@ -54,6 +57,7 @@ export const mdEnhancePlugin: Plugin = ( const tasklistEnable = getStatus("tasklist", true); const mermaidEnable = getStatus("mermaid"); const presentationEnable = getStatus("presentation"); + const tabsEnable = getStatus("tabs"); const texEnable = getStatus("tex"); const katexOptions: KatexOptions = { @@ -91,6 +95,9 @@ export const mdEnhancePlugin: Plugin = ( "@CodeGroupItem": codegroupEnable ? resolve(__dirname, "../client/components/CodeGroupItem.vue") : noopModule, + "@CodeTabs": codeTabsEnable + ? resolve(__dirname, "../client/components/CodeTabs.js") + : noopModule, "@ECharts": echartsEnable ? resolve(__dirname, "../client/components/ECharts.vue") : noopModule, @@ -103,6 +110,9 @@ export const mdEnhancePlugin: Plugin = ( "@Presentation": presentationEnable ? resolve(__dirname, "../client/components/Presentation.vue") : noopModule, + "@Tabs": tabsEnable + ? resolve(__dirname, "../client/components/Tabs.js") + : noopModule, }, define: (): Record => ({ @@ -155,6 +165,7 @@ export const mdEnhancePlugin: Plugin = ( typeof options.tasklist === "object" ? options.tasklist : {}, ]); if (chartEnable) md.use(chart); + if (codeTabsEnable) md.use(codeTabs); if (demoEnable) { md.use(normalDemo); md.use(vueDemo); @@ -172,6 +183,7 @@ export const mdEnhancePlugin: Plugin = ( if (texEnable) md.use(katex, katexOptions); if (presentationEnable) md.use(presentation); if (getStatus("stylize")) md.use(stylize, options.stylize); + if (tabsEnable) md.use(tabs); }, plugins: getPluginConfig(options, context), diff --git a/packages/md-enhance/src/types/declare.d.ts b/packages/md-enhance/src/types/declare.d.ts index 7cf36081c..1b9615b92 100644 --- a/packages/md-enhance/src/types/declare.d.ts +++ b/packages/md-enhance/src/types/declare.d.ts @@ -806,6 +806,12 @@ declare module "@CodeGroupItem" { export default vue; } +declare module "@CodeTabs" { + import vue from "vue"; + + export default vue; +} + declare module "@ECharts" { import vue from "vue"; @@ -829,3 +835,9 @@ declare module "@Presentation" { export default vue; } + +declare module "@Tabs" { + import vue from "vue"; + + export default vue; +} diff --git a/packages/md-enhance/src/types/options.d.ts b/packages/md-enhance/src/types/options.d.ts index fff6fa491..6b46cfc7c 100644 --- a/packages/md-enhance/src/types/options.d.ts +++ b/packages/md-enhance/src/types/options.d.ts @@ -75,23 +75,23 @@ export interface MarkdownEnhanceOptions { */ container?: boolean; - // /** - // * Whether to enable tabs. - // * - // * 是否启用标签页分组。 - // * - // * @default false - // */ - // tabs?: boolean; + /** + * Whether to enable tabs. + * + * 是否启用标签页分组。 + * + * @default false + */ + tabs?: boolean; - // /** - // * Whether to enable codetabs. - // * - // * 是否启用代码组。 - // * - // * @default false - // */ - // codetabs?: boolean; + /** + * Whether to enable codetabs. + * + * 是否启用代码组。 + * + * @default false + */ + codetabs?: boolean; /** * Whether to enable codegroup.