diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index a24c1be1f109..b800ec8b0eb9 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -87,6 +87,7 @@ function sidebarGuide() {
collapsible: true,
items: [
{ text: 'Markdown', link: '/guide/markdown' },
+ { text: 'Code Group', link: '/guide/test' },
{ text: 'Asset Handling', link: '/guide/asset-handling' },
{ text: 'Frontmatter', link: '/guide/frontmatter' },
{ text: 'Using Vue in Markdown', link: '/guide/using-vue' },
diff --git a/docs/guide/test.md b/docs/guide/test.md
new file mode 100644
index 000000000000..741b4cda3c48
--- /dev/null
+++ b/docs/guide/test.md
@@ -0,0 +1,81 @@
+# Code Groups
+
+test
+
+:::code-group
+
+```js [app.vue]
+
+
+
+
+
+```
+
+```js{3,4} [layouts/custom.vue]
+
+
+ Some *custom* layout
+
+
+
+```
+
+```js{1-3,5} [layouts/default.vue]
+export default {
+ name: 'MyComponent'
+ // ...
+}
+
+
+ Some *custom* layout
+
+
+
+```
+
+```python
+import torch as th
+print("Hello world")
+```
+
+:::
+
+
+:::code-group
+
+```js
+printf("111")
+```
+
+
+```python
+import torch as th
+print("Hello world")
+```
+
+:::
+
+
+```
+.
+├─ index.md
+├─ foo
+│ ├─ index.md
+│ ├─ one.md
+│ └─ two.md
+└─ bar
+ ├─ index.md
+ ├─ three.md
+ └─ four.md
+```
+
+
+```md
+[Home](/)
+[foo](/foo/)
+[foo heading](./#heading)
+[bar - three](../bar/three)
+[bar - three](../bar/three.md)
+[bar - four](../bar/four.html)
+```
diff --git a/src/client/app/composables/codeGroup.ts b/src/client/app/composables/codeGroup.ts
new file mode 100644
index 000000000000..927885d536f6
--- /dev/null
+++ b/src/client/app/composables/codeGroup.ts
@@ -0,0 +1,20 @@
+import { inBrowser } from '../utils.js'
+
+export function useCodeGroup() {
+ if (inBrowser) {
+ window.addEventListener('click', (e) => {
+ const el = e.target as HTMLElement
+ if (el.matches('.code-group>.tabs-header>button')) {
+ const codeblocks =
+ el.parentElement!.parentElement!.querySelectorAll('.code-block')
+ const buttons =
+ el.parentElement!.parentElement!.querySelectorAll('button')
+ const index = parseInt(el.getAttribute('tab-index')!)
+ codeblocks.forEach((ele) => ele.classList.remove('active'))
+ buttons.forEach((ele) => ele.classList.remove('active'))
+ el.classList.add('active')
+ codeblocks[index].classList.add('active')
+ }
+ })
+ }
+}
diff --git a/src/client/app/index.ts b/src/client/app/index.ts
index 84bd1c23da8b..cc9d93e1391c 100644
--- a/src/client/app/index.ts
+++ b/src/client/app/index.ts
@@ -17,6 +17,7 @@ import { dataSymbol, initData } from './data.js'
import { Content } from './components/Content.js'
import { ClientOnly } from './components/ClientOnly.js'
import { useCopyCode } from './composables/copyCode.js'
+import { useCodeGroup } from './composables/codeGroup.js'
const NotFound = Theme.NotFound || (() => '404 Not Found')
@@ -44,6 +45,9 @@ const VitePressApp = defineComponent({
// setup global copy code handler
useCopyCode()
+ // setup code group button handler
+ useCodeGroup()
+
if (Theme.setup) Theme.setup()
return () => h(Theme.Layout)
}
diff --git a/src/client/theme-default/styles/components/vp-code.css b/src/client/theme-default/styles/components/vp-code.css
index ee74e5b27d70..158570608470 100644
--- a/src/client/theme-default/styles/components/vp-code.css
+++ b/src/client/theme-default/styles/components/vp-code.css
@@ -5,3 +5,39 @@
html:not(.dark) .vp-code-dark {
display: none;
}
+
+.code-group .tabs-header {
+ border-radius: 8px 8px 0 0;
+ padding: 0 12px 0 12px;
+ background-color: var(--vp-code-block-tab-header-bg);
+ transition: background-color 0.5s;
+}
+
+.code-group .tabs-header button {
+ padding: 6px 8px 6px 8px;
+ margin: 8px 0 8px 0;
+ border-radius: 8px;
+ color: white;
+ outline: none;
+}
+
+.code-group .tabs-header button:hover {
+ color: var(--vp-c-gray-light-4);
+}
+
+.code-group .tabs-header button.active {
+ background-color: var(--vp-c-gray-dark-3);
+}
+
+.code-group .code-block {
+ display: none;
+}
+
+.code-group .code-block.active {
+ display: block;
+}
+
+.vp-doc .code-group div[class*='language-'] {
+ border-radius: 0 0 8px 8px;
+ margin-top: 0;
+}
diff --git a/src/client/theme-default/styles/components/vp-doc.css b/src/client/theme-default/styles/components/vp-doc.css
index 909fbc108f7c..3c803a5c8d18 100644
--- a/src/client/theme-default/styles/components/vp-doc.css
+++ b/src/client/theme-default/styles/components/vp-doc.css
@@ -259,23 +259,14 @@
.vp-doc div[class*='language-'] {
position: relative;
- margin: 16px -24px;
background-color: var(--vp-code-block-bg);
overflow-x: auto;
transition: background-color 0.5s;
}
-@media (min-width: 640px) {
- .vp-doc div[class*='language-'] {
- border-radius: 8px;
- margin: 16px 0;
- }
-}
-
-@media (max-width: 639px) {
- .vp-doc li div[class*='language-'] {
- border-radius: 8px 0 0 8px;
- }
+.vp-doc div[class*='language-'] {
+ border-radius: 8px;
+ margin: 16px 0;
}
.vp-doc div[class*='language-'] + div[class*='language-'],
@@ -405,7 +396,7 @@
content: 'Copied';
}
-.vp-doc [class*='language-'] > span.lang {
+.vp-doc [class*='language-'] > span.code-title {
position: absolute;
top: 6px;
right: 12px;
@@ -416,8 +407,8 @@
transition: color 0.4s, opacity 0.4s;
}
-.vp-doc [class*='language-']:hover > button.copy + span.lang,
-.vp-doc [class*='language-'] > button.copy:focus + span.lang {
+.vp-doc [class*='language-']:hover > button.copy + span.code-title,
+.vp-doc [class*='language-'] > button.copy:focus + span.code-title {
opacity: 0;
}
diff --git a/src/client/theme-default/styles/vars.css b/src/client/theme-default/styles/vars.css
index dff17f204251..fbe43984cc95 100644
--- a/src/client/theme-default/styles/vars.css
+++ b/src/client/theme-default/styles/vars.css
@@ -204,6 +204,7 @@
--vp-code-block-color: var(--vp-c-text-dark-1);
--vp-code-block-bg: #292d3e;
+ --vp-code-block-tab-header-bg: var(--vp-c-indigo);
--vp-code-line-highlight-color: rgba(0, 0, 0, 0.5);
--vp-code-line-number-color: var(--vp-c-text-dark-3);
diff --git a/src/node/markdown/plugins/codeGroup.ts b/src/node/markdown/plugins/codeGroup.ts
new file mode 100644
index 000000000000..a18b6f44e914
--- /dev/null
+++ b/src/node/markdown/plugins/codeGroup.ts
@@ -0,0 +1,24 @@
+import Token from 'markdown-it/lib/token'
+
+export const extractCodeTitleAndLang = (token: Token): [string, string] => {
+ const RE = /(\w*)(?:{[\d,-]+})?\s*\[(.+)\]/
+ const hint = token.info
+ .trim()
+ .replace(codeGroupInternalActiveMark, '')
+ .replace(/-vue$/, '')
+ let codeTitle = ''
+ let lang = hint
+ if (RE.test(hint)) {
+ const matchGroup = RE.exec(hint)
+ if (matchGroup && matchGroup.length == 3) {
+ lang = matchGroup[1].trim()
+ codeTitle = matchGroup[2]
+ }
+ } else {
+ // Use language name as code title if not specified
+ codeTitle = lang === 'vue-html' ? 'template' : lang
+ }
+ return [codeTitle, lang]
+}
+
+export const codeGroupInternalActiveMark = '#vitepress-internal-active#'
diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts
index 40480a10ce41..cb5a38f17632 100644
--- a/src/node/markdown/plugins/containers.ts
+++ b/src/node/markdown/plugins/containers.ts
@@ -2,6 +2,10 @@ import MarkdownIt from 'markdown-it'
import { RenderRule } from 'markdown-it/lib/renderer'
import Token from 'markdown-it/lib/token'
import container from 'markdown-it-container'
+import {
+ extractCodeTitleAndLang,
+ codeGroupInternalActiveMark
+} from './codeGroup'
export const containerPlugin = (md: MarkdownIt) => {
md.use(...createContainer('tip', 'TIP', md))
@@ -14,6 +18,7 @@ export const containerPlugin = (md: MarkdownIt) => {
render: (tokens: Token[], idx: number) =>
tokens[idx].nesting === 1 ? `
\n` : `
\n`
})
+ .use(...createCodeGroup())
.use(container, 'raw', {
render: (tokens: Token[], idx: number) =>
tokens[idx].nesting === 1 ? `\n` : `
\n`
@@ -47,3 +52,51 @@ function createContainer(
}
]
}
+
+function createCodeGroup(): ContainerArgs {
+ const klass = 'code-group'
+ return [
+ container,
+ klass,
+ {
+ render(tokens, idx) {
+ if (tokens[idx].nesting === 1) {
+ const startTokenId = idx
+ const endTokenId =
+ tokens
+ .slice(startTokenId)
+ .findIndex(
+ (token) =>
+ token.nesting == -1 &&
+ token.type === 'container_code-group_close'
+ ) + startTokenId
+ const codeGroupTokens = tokens.slice(startTokenId + 1, endTokenId)
+
+ // Mark first code block as active
+ const firstCodeBlock = codeGroupTokens.findIndex(
+ (token) => token.type === 'fence' && token.tag === 'code'
+ )
+ tokens[
+ startTokenId + 1 + firstCodeBlock
+ ].info += ` ${codeGroupInternalActiveMark}`
+ const codeTitles = codeGroupTokens
+ .filter((token) => token.type === 'fence' && token.tag === 'code')
+ .map((token) => extractCodeTitleAndLang(token)[0])
+ let headerBlock = `\n\n`
+ return headerBlock
+ } else {
+ return `
\n`
+ }
+ }
+ }
+ ]
+}
diff --git a/src/node/markdown/plugins/preWrapper.ts b/src/node/markdown/plugins/preWrapper.ts
index 7a6b22e7eca0..b18db0c0e032 100644
--- a/src/node/markdown/plugins/preWrapper.ts
+++ b/src/node/markdown/plugins/preWrapper.ts
@@ -8,15 +8,27 @@
// 4.
import MarkdownIt from 'markdown-it'
+import {
+ codeGroupInternalActiveMark,
+ extractCodeTitleAndLang
+} from './codeGroup'
export const preWrapperPlugin = (md: MarkdownIt) => {
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const [tokens, idx] = args
- const lang = tokens[idx].info.trim().replace(/-vue$/, '')
+ const token = tokens[idx]
+ const [codeTitle, lang] = extractCodeTitleAndLang(token)
+ const isActive = token.info.includes(codeGroupInternalActiveMark)
+ token.info = tokens[idx].info.replace(/\[(.+)\]/, '')
const rawCode = fence(...args)
- return `${
- lang === 'vue-html' ? 'template' : lang
- }${rawCode}
`
+
+ return `
+
+
+ ${codeTitle}
+ ${rawCode}
+
+
`
}
}