diff --git a/.gitignore b/.gitignore index 3a95953c..7bf9a27d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/__tests__/e2e/.vitepress/cache /coverage /src/client/shared.ts /src/node/shared.ts diff --git a/docs/guide/markdown.md b/docs/guide/markdown.md index ab2e8fb4..7936e9a8 100644 --- a/docs/guide/markdown.md +++ b/docs/guide/markdown.md @@ -371,7 +371,7 @@ export default { ```js export default { - data () { + data() { return { msg: 'Highlighted!' // [!code hl] } @@ -381,7 +381,7 @@ export default { ## Focus in Code Blocks -Adding the `// [!code focus]` comment on a line will focus it and blur the other parts of the code. +Adding the `// [!code focus]` comment on a line will focus it and blur the other parts of the code. Additionally, you can define a number of lines to focus using `// [!code focus:]`. @@ -405,7 +405,7 @@ export default { ```js export default { - data () { + data() { return { msg: 'Focused!' // [!code focus] } @@ -413,9 +413,9 @@ export default { } ``` -## Colored diffs in Code Blocks +## Colored Diffs in Code Blocks -Adding the `// [!code --]` or `// [!code ++]` comments on a line will create a diff of that line, while keeping the colors of the codeblock. +Adding the `// [!code --]` or `// [!code ++]` comments on a line will create a diff of that line, while keeping the colors of the codeblock. **Input** @@ -447,7 +447,7 @@ export default { } ``` -## Errors and warnings +## Errors and Warnings in Code Blocks Adding the `// [!code warning]` or `// [!code error]` comments on a line will color it accordingly. @@ -472,7 +472,7 @@ export default { ```js export default { - data () { + data() { return { msg: 'Error', // [!code error] msg: 'Warning' // [!code warning] @@ -549,11 +549,72 @@ You can also specify the language inside the braces (`{}`) like this: <<< @/snippets/snippet.cs{c#} + <<< @/snippets/snippet.cs{1,2,4-6 c#} ``` This is helpful if source language cannot be inferred from your file extension. +## Code Groups + +You can group multiple code blocks like this: + +**Input** + +````md +::: code-group + +```js [config.js] +/** + * @type {import('vitepress').UserConfig} + */ +const config = { + // ... +} + +export default config +``` + +```ts [config.ts] +import type { UserConfig } from 'vitepress' + +const config: UserConfig = { + // ... +} + +export default config +``` + +::: +```` + +**Output** + +::: code-group + +```js [config.js] +/** + * @type {import('vitepress').UserConfig} + */ +const config = { + // ... +} + +export default config +``` + +```ts [config.ts] +import type { UserConfig } from 'vitepress' + +const config: UserConfig = { + // ... +} + +export default config +``` + +::: + ## Markdown File Inclusion You can include a markdown file in another markdown file like this: diff --git a/docs/test.md b/docs/test.md new file mode 100644 index 00000000..1e9f9cbe --- /dev/null +++ b/docs/test.md @@ -0,0 +1,84 @@ +# Code Groups + +::: code-group + +```txt-vue{1} +{{ 1 + 1 }} +``` + +```js [app.vue] + +``` + + + +```vue-html{3,4} [layouts/custom.vue] + +``` + +```js{1-3,5} [layouts/default.vue] +export default { + name: 'MyComponent' + // ... +} + +``` + +::: + +- in list + +- ::: code-group + + ```js + printf('111') + ``` + + ```python + import torch as th + print("Hello world") + ``` + + ``` + import torch as th + print("Hello world") + ``` + + ::: + +``` +. +├─ index.md +├─ foo +│ ├─ index.md +│ ├─ one.md +│ └─ two.md +└─ bar + ├─ index.md + ├─ three.md + └─ four.md +``` + +- ```md{1-3,5} + [Home](/) + [foo](/foo/) + [foo heading](./#heading) + [bar - three](../bar/three) + [bar - three](../bar/three.md) + [bar - four](../bar/four.html) + ``` diff --git a/package.json b/package.json index 74d8a441..7de34198 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@vue/devtools-api": "^6.4.5", "@vueuse/core": "^9.6.0", "body-scroll-lock": "4.0.0-beta.0", + "nanoid": "3.3.4", "shiki": "^0.11.1", "vite": "^4.0.0", "vue": "^3.2.45" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 745dbf26..aedac2a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,7 @@ importers: markdown-it-emoji: ^2.0.2 micromatch: ^4.0.5 minimist: ^1.2.7 + nanoid: 3.3.4 npm-run-all: ^4.1.5 ora: ^5.4.1 picocolors: ^1.0.0 @@ -91,6 +92,7 @@ importers: '@vue/devtools-api': 6.4.5 '@vueuse/core': 9.6.0_vue@3.2.45 body-scroll-lock: 4.0.0-beta.0 + nanoid: 3.3.4 shiki: 0.11.1 vite: 4.0.0_@types+node@18.11.13 vue: 3.2.45 diff --git a/src/client/app/composables/codeGroups.ts b/src/client/app/composables/codeGroups.ts new file mode 100644 index 00000000..2c5c2cb0 --- /dev/null +++ b/src/client/app/composables/codeGroups.ts @@ -0,0 +1,23 @@ +import { inBrowser } from 'vitepress' + +export function useCodeGroups() { + if (inBrowser) { + window.addEventListener('click', (e) => { + const el = e.target as HTMLInputElement + + if (el.matches('.vp-code-group input')) { + // input <- .tabs <- .vp-code-group + const group = el.parentElement?.parentElement + const i = Array.from(group?.querySelectorAll('input') || []).indexOf(el) + + const current = group?.querySelector('div[class*="language-"].active') + const next = group?.querySelectorAll('div[class*="language-"]')?.[i] + + if (current && next && current !== next) { + current.classList.remove('active') + next.classList.add('active') + } + } + }) + } +} diff --git a/src/client/app/composables/copyCode.ts b/src/client/app/composables/copyCode.ts index 224f9209..8d3afb52 100644 --- a/src/client/app/composables/copyCode.ts +++ b/src/client/app/composables/copyCode.ts @@ -1,4 +1,4 @@ -import { inBrowser } from '../utils.js' +import { inBrowser } from 'vitepress' export function useCopyCode() { if (inBrowser) { diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 84bd1c23..9deba800 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 { useCodeGroups } from './composables/codeGroups.js' const NotFound = Theme.NotFound || (() => '404 Not Found') @@ -43,6 +44,8 @@ const VitePressApp = defineComponent({ // setup global copy code handler useCopyCode() + // setup global code groups handler + useCodeGroups() if (Theme.setup) Theme.setup() return () => h(Theme.Layout) diff --git a/src/client/theme-default/index.ts b/src/client/theme-default/index.ts index a0815806..2d369d21 100644 --- a/src/client/theme-default/index.ts +++ b/src/client/theme-default/index.ts @@ -4,6 +4,7 @@ import './styles/base.css' import './styles/utils.css' import './styles/components/custom-block.css' import './styles/components/vp-code.css' +import './styles/components/vp-code-group.css' import './styles/components/vp-doc.css' import './styles/components/vp-sponsor.css' diff --git a/src/client/theme-default/styles/components/vp-code-group.css b/src/client/theme-default/styles/components/vp-code-group.css new file mode 100644 index 00000000..0384bae3 --- /dev/null +++ b/src/client/theme-default/styles/components/vp-code-group.css @@ -0,0 +1,87 @@ +.vp-code-group { + margin-top: 16px; +} + +.vp-code-group .tabs { + position: relative; + display: flex; + margin-right: -24px; + margin-left: -24px; + padding: 0 12px; + background-color: var(--vp-code-tab-bg); + overflow: auto; +} + +.vp-code-group .tabs::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 1px; + background-color: var(--vp-code-tab-divider); + content: ''; +} + +@media (min-width: 640px) { + .vp-code-group .tabs { + margin-right: 0; + margin-left: 0; + border-radius: 8px 8px 0 0; + } +} + +.vp-code-group .tabs input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.vp-code-group .tabs label { + position: relative; + display: inline-block; + border-bottom: 1px solid transparent; + padding: 0 12px; + line-height: 48px; + font-size: 14px; + font-weight: 500; + color: var(--vp-code-tab-text-color); + background-color: var(--vp-code-tab-bg); + white-space: nowrap; + cursor: pointer; + transition: color 0.25s; +} + +.vp-code-group .tabs label::after { + position: absolute; + right: 8px; + bottom: -1px; + left: 8px; + z-index: 10; + height: 1px; + content: ''; + background-color: transparent; + transition: background-color 0.25s; +} + +.vp-code-group label:hover { + color: var(--vp-code-tab-hover-text-color); +} + +.vp-code-group input:checked + label { + color: var(--vp-code-tab-active-text-color); +} + +.vp-code-group input:checked + label::after { + background-color: var(--vp-code-tab-active-bar-color); +} + +.vp-code-group div[class*='language-'] { + display: none; + margin-top: 0 !important; + border-top-left-radius: 0 !important; + border-top-right-radius: 0 !important; +} + +.vp-code-group div[class*='language-'].active { + display: block; +} diff --git a/src/client/theme-default/styles/vars.css b/src/client/theme-default/styles/vars.css index 14e50b40..87ae88de 100644 --- a/src/client/theme-default/styles/vars.css +++ b/src/client/theme-default/styles/vars.css @@ -228,10 +228,20 @@ --vp-code-copy-code-hover-bg: rgba(255, 255, 255, 0.05); --vp-code-copy-code-active-text: var(--vp-c-text-dark-2); + + --vp-code-tab-text-color: var(--vp-c-text-dark-2); + --vp-code-tab-bg: var(--vp-code-block-bg); + --vp-code-tab-divider: var(--vp-c-divider-dark-2); + --vp-code-tab-hover-text-color: var(--vp-c-text-dark-1); + --vp-code-tab-active-text-color: var(--vp-c-text-dark-1); + --vp-code-tab-active-bar-color: var(--vp-c-brand); } .dark { - --vp-code-block-bg: var(--vp-c-bg-alt); + --vp-code-block-bg: var(--vp-c-black); + + /* --vp-code-tab: var(--vp-c-black-mute);*/ + /* --vp-code-tab-hover: var(--vp-c-gray-dark-4);*/ } /** diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 40480a10..f393b180 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -2,6 +2,8 @@ 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 { nanoid } from 'nanoid' +import { extractTitle } from './preWrapper' export const containerPlugin = (md: MarkdownIt) => { md.use(...createContainer('tip', 'TIP', md)) @@ -18,6 +20,7 @@ export const containerPlugin = (md: MarkdownIt) => { render: (tokens: Token[], idx: number) => tokens[idx].nesting === 1 ? `
\n` : `
\n` }) + .use(...createCodeGroup()) } type ContainerArgs = [typeof container, string, { render: RenderRule }] @@ -47,3 +50,42 @@ function createContainer( } ] } + +function createCodeGroup(): ContainerArgs { + return [ + container, + 'code-group', + { + render(tokens, idx) { + if (tokens[idx].nesting === 1) { + const name = nanoid(5) + let tabs = '' + let checked = 'checked="checked"' + + for ( + let i = idx + 1; + !( + tokens[i].nesting === -1 && + tokens[i].type === 'container_code-group_close' + ); + ++i + ) { + if (tokens[i].type === 'fence' && tokens[i].tag === 'code') { + const title = extractTitle(tokens[i].info) + const id = nanoid(7) + tabs += `` + + if (checked) { + tokens[i].info += ' active' + checked = '' + } + } + } + + return `
${tabs}
\n` + } + return `
\n` + } + } + ] +} diff --git a/src/node/markdown/plugins/highlight.ts b/src/node/markdown/plugins/highlight.ts index 46e3f92a..d6db23b0 100644 --- a/src/node/markdown/plugins/highlight.ts +++ b/src/node/markdown/plugins/highlight.ts @@ -20,8 +20,9 @@ import type { ThemeOptions } from '../markdown' * [{ line: number, classes: string[] }] */ const attrsToLines = (attrs: string): HtmlRendererOptions['lineOptions'] => { + attrs = attrs.replace(/.*?([\d,-]+).*/, '$1').trim() const result: number[] = [] - if (!attrs.trim()) { + if (!attrs) { return [] } attrs diff --git a/src/node/markdown/plugins/preWrapper.ts b/src/node/markdown/plugins/preWrapper.ts index 7a6b22e7..547b91e4 100644 --- a/src/node/markdown/plugins/preWrapper.ts +++ b/src/node/markdown/plugins/preWrapper.ts @@ -1,22 +1,24 @@ -// markdown-it plugin for wrapping
 ... 
. -// -// If your plugin was chained before preWrapper, you can add additional element directly. -// If your plugin was chained after preWrapper, you can use these slots: -// 1. -// 2. -// 3. -// 4. - import MarkdownIt from 'markdown-it' -export const preWrapperPlugin = (md: MarkdownIt) => { +export function 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 { info } = args[0][args[1]] + const lang = extractLang(info) const rawCode = fence(...args) - return `
${ - lang === 'vue-html' ? 'template' : lang - }${rawCode}
` + return `
${lang}${rawCode}
` } } + +export function extractTitle(info: string) { + return info.match(/\[(.*)\]/)?.[1] || extractLang(info) || 'txt' +} + +const extractLang = (info: string) => { + return info + .trim() + .replace(/(-vue|{| ).*$/, '') + .replace(/^vue-html$/, 'template') +}