diff --git a/docs/en/guide/getting-started.md b/docs/en/guide/getting-started.md index 6382b204a096..daf955815e94 100644 --- a/docs/en/guide/getting-started.md +++ b/docs/en/guide/getting-started.md @@ -15,7 +15,7 @@ You can try VitePress directly in your browser on [StackBlitz](https://vitepress VitePress can be used on its own, or be installed into an existing project. In both cases, you can install it with: -::: code-group +::: code-group :package-manager ```sh [npm] $ npm add -D vitepress @@ -49,7 +49,7 @@ VitePress is an ESM-only package. Don't use `require()` to import it, and make s VitePress ships with a command line setup wizard that will help you scaffold a basic project. After installation, start the wizard by running: -::: code-group +::: code-group :package-manager ```sh [npm] $ npx vitepress init @@ -144,7 +144,7 @@ The tool should have also injected the following npm scripts to your `package.js The `docs:dev` script will start a local dev server with instant hot updates. Run it with the following command: -::: code-group +::: code-group :package-manager ```sh [npm] $ npm run docs:dev @@ -166,7 +166,7 @@ $ bun run docs:dev Instead of npm scripts, you can also invoke VitePress directly with: -::: code-group +::: code-group :package-manager ```sh [npm] $ npx vitepress dev docs diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index 89d826d5188d..27fc972ef9b4 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -776,6 +776,82 @@ You can also [import snippets](#import-code-snippets) in code groups: ::: +### Code Group Synchronization + +Code groups can be synchronized across the page using a key. When you click on a tab in one code group, all other code groups with the same key will automatically switch to the corresponding tab. The selected tab is also reflected in the URL as a query parameter. + +**Input** + +````md +::: code-group :package-manager + +```bash [npm] +npm install vitepress +``` + +```bash [yarn] +yarn add vitepress +``` + +```bash [pnpm] +pnpm add vitepress +``` + +::: + +::: code-group :package-manager + +```bash [npm] +npm run dev +``` + +```bash [yarn] +yarn dev +``` + +```bash [pnpm] +pnpm dev +``` + +::: +```` + +**Output** + +::: code-group :package-manager + +```bash [npm] +npm install vitepress +``` + +```bash [yarn] +yarn add vitepress +``` + +```bash [pnpm] +pnpm add vitepress +``` + +::: + +::: code-group :package-manager + +```bash [npm] +npm run dev +``` + +```bash [yarn] +yarn dev +``` + +```bash [pnpm] +pnpm dev +``` + +::: + +When you select a tab (e.g., "pnpm"), both code groups will switch to show the pnpm version, and the URL will update to include `?package-manager=pnpm`. This makes it easy to share links that preserve the user's tab selection. + ## Markdown File Inclusion You can include a markdown file in another markdown file, even nested. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b86db0b1a6d7..799e1c63aa02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,21 +1,9 @@ lockfileVersion: '9.0' settings: - autoInstallPeers: false + autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - ora>string-width: ^5 - vite: npm:rolldown-vite@latest - -patchedDependencies: - '@types/mdurl@2.0.0': - hash: 3460e7d18ce390685cf4b8d8237fb20df9ad952c1336f479995a508a6395bfa4 - path: patches/@types__mdurl@2.0.0.patch - markdown-it-anchor@9.2.0: - hash: cdc28e7c329be30688ad192126ba505446611fbe526ad51483e4b1287aa35cf9 - path: patches/markdown-it-anchor@9.2.0.patch - importers: .: @@ -40,7 +28,7 @@ importers: version: 3.8.1 '@vitejs/plugin-vue': specifier: ^6.0.0 - version: 6.0.0(rolldown-vite@7.0.10(@types/node@24.1.0)(esbuild@0.25.8)(jiti@1.21.7)(yaml@2.8.0))(vue@3.5.18(typescript@5.8.3)) + version: 6.0.0(vite@7.1.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.8.0))(vue@3.5.18(typescript@5.8.3)) '@vue/devtools-api': specifier: ^7.7.7 version: 7.7.7 @@ -66,8 +54,8 @@ importers: specifier: ^3.8.1 version: 3.8.1 vite: - specifier: npm:rolldown-vite@latest - version: rolldown-vite@7.0.10(@types/node@24.1.0)(esbuild@0.25.8)(jiti@1.21.7)(yaml@2.8.0) + specifier: ^7.0.6 + version: 7.1.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.8.0) vue: specifier: ^3.5.18 version: 3.5.18(typescript@5.8.3) @@ -200,7 +188,7 @@ importers: version: 14.1.0 markdown-it-anchor: specifier: ^9.2.0 - version: 9.2.0(patch_hash=cdc28e7c329be30688ad192126ba505446611fbe526ad51483e4b1287aa35cf9)(@types/markdown-it@14.1.2)(markdown-it@14.1.0) + version: 9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.1.0) markdown-it-async: specifier: ^2.2.0 version: 2.2.0 @@ -335,7 +323,7 @@ importers: version: link:.. vitepress-plugin-group-icons: specifier: ^1.6.1 - version: 1.6.1(@types/node@24.1.0)(esbuild@0.25.8)(jiti@1.21.7)(markdown-it@14.1.0)(yaml@2.8.0) + version: 1.6.1(markdown-it@14.1.0)(vite@7.1.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.8.0)) vitepress-plugin-llms: specifier: ^1.7.1 version: 1.7.1 @@ -3089,10 +3077,51 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite@7.1.1: + resolution: {integrity: sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitepress-plugin-group-icons@1.6.1: resolution: {integrity: sha512-eoFlFAhAy/yTZDbaIgA/nMbjVYXkf8pz8rr75MN2VCw7yH60I3cw6bW5EuwddAeafZtBqbo8OsEGU7TIWFiAjg==} peerDependencies: markdown-it: '>=14' + vite: '>=3' vitepress-plugin-llms@1.7.1: resolution: {integrity: sha512-RF5hl2vGxKhbcGirLLUhIlnWNSaoscPKBVnKaGxrKzj76i+mI+HBvfi/DF7a1u2L05LAnf7KSBkEVsMexczsAg==} @@ -3834,13 +3863,13 @@ snapshots: '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 - '@types/mdurl': 2.0.0(patch_hash=3460e7d18ce390685cf4b8d8237fb20df9ad952c1336f479995a508a6395bfa4) + '@types/mdurl': 2.0.0 '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 - '@types/mdurl@2.0.0(patch_hash=3460e7d18ce390685cf4b8d8237fb20df9ad952c1336f479995a508a6395bfa4)': {} + '@types/mdurl@2.0.0': {} '@types/minimist@1.2.5': {} @@ -3881,10 +3910,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@6.0.0(rolldown-vite@7.0.10(@types/node@24.1.0)(esbuild@0.25.8)(jiti@1.21.7)(yaml@2.8.0))(vue@3.5.18(typescript@5.8.3))': + '@vitejs/plugin-vue@6.0.0(vite@7.1.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.8.0))(vue@3.5.18(typescript@5.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.19 - vite: rolldown-vite@7.0.10(@types/node@24.1.0)(esbuild@0.25.8)(jiti@1.21.7)(yaml@2.8.0) + vite: 7.1.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.8.0) vue: 3.5.18(typescript@5.8.3) '@vitest/expect@4.0.0-beta.4': @@ -4938,7 +4967,7 @@ snapshots: mark.js@8.11.1: {} - markdown-it-anchor@9.2.0(patch_hash=cdc28e7c329be30688ad192126ba505446611fbe526ad51483e4b1287aa35cf9)(@types/markdown-it@14.1.2)(markdown-it@14.1.0): + markdown-it-anchor@9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.1.0): dependencies: '@types/markdown-it': 14.1.2 markdown-it: 14.1.0 @@ -5305,7 +5334,7 @@ snapshots: is-unicode-supported: 2.1.0 log-symbols: 6.0.0 stdin-discarder: 0.2.2 - string-width: 5.1.2 + string-width: 7.2.0 strip-ansi: 7.1.0 oxc-minify@0.78.0: @@ -5973,26 +6002,30 @@ snapshots: - tsx - yaml - vitepress-plugin-group-icons@1.6.1(@types/node@24.1.0)(esbuild@0.25.8)(jiti@1.21.7)(markdown-it@14.1.0)(yaml@2.8.0): + vite@7.1.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.8.0): + dependencies: + esbuild: 0.25.8 + fdir: 6.4.6(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.45.1 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 24.1.0 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.1 + yaml: 2.8.0 + + vitepress-plugin-group-icons@1.6.1(markdown-it@14.1.0)(vite@7.1.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.8.0)): dependencies: '@iconify-json/logos': 1.2.5 '@iconify-json/vscode-icons': 1.2.23 '@iconify/utils': 2.3.0 markdown-it: 14.1.0 - vite: rolldown-vite@7.0.10(@types/node@24.1.0)(esbuild@0.25.8)(jiti@1.21.7)(yaml@2.8.0) + vite: 7.1.1(@types/node@24.1.0)(jiti@1.21.7)(lightningcss@1.30.1)(yaml@2.8.0) transitivePeerDependencies: - - '@types/node' - - esbuild - - jiti - - less - - sass - - sass-embedded - - stylus - - sugarss - supports-color - - terser - - tsx - - yaml vitepress-plugin-llms@1.7.1: dependencies: diff --git a/src/client/app/composables/codeGroups.ts b/src/client/app/composables/codeGroups.ts index d8a38ba72741..b5ec45a57fd6 100644 --- a/src/client/app/composables/codeGroups.ts +++ b/src/client/app/composables/codeGroups.ts @@ -1,46 +1,155 @@ import { inBrowser, onContentUpdated } from 'vitepress' +const codeGroupCache = new Map() + export function useCodeGroups() { if (import.meta.env.DEV) { onContentUpdated(() => { + clearCache() + document.querySelectorAll('.vp-code-group > .blocks').forEach((el) => { Array.from(el.children).forEach((child) => { child.classList.remove('active') }) el.children[0].classList.add('active') }) + + handleQueryParamNavigation() }) } if (inBrowser) { + const handleUrlChange = () => { + handleQueryParamNavigation() + } + + handleQueryParamNavigation() + + window.addEventListener('popstate', handleUrlChange) + 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 if (!group) return - const i = Array.from(group.querySelectorAll('input')).indexOf(el) - if (i < 0) return + const label = group?.querySelector(`label[for="${el.id}"]`) + if (!label) return + + if (!activateTab(group, el)) return - const blocks = group.querySelector('.blocks') - if (!blocks) return + label.scrollIntoView({ block: 'nearest' }) - const current = Array.from(blocks.children).find((child) => - child.classList.contains('active') + // Get the group key and tab title for URL update and sync + const groupKey = group.getAttribute('data-group-key') + const tabTitle = label.getAttribute('data-title')?.toLowerCase() + + if (groupKey && tabTitle) { + syncCodeGroupsByKeyAndValue(groupKey, tabTitle, group) + updateUrl(groupKey, tabTitle) + } + } + }) + } +} + +function getCodeGroupsByKey(groupKey: string): HTMLElement[] { + if (!codeGroupCache.has(groupKey)) { + codeGroupCache.set( + groupKey, + Array.from( + document.querySelectorAll( + `.vp-code-group[data-group-key="${groupKey}"]` ) - if (!current) return + ) + ) + } + return codeGroupCache.get(groupKey) || [] +} + +function clearCache() { + codeGroupCache.clear() +} - const next = blocks.children[i] - if (!next || current === next) return +function activateTab(group: HTMLElement, input: HTMLInputElement): boolean { + const inputs = Array.from( + group.querySelectorAll('input') + ) as HTMLInputElement[] + const index = inputs.indexOf(input) + if (index < 0) return false - current.classList.remove('active') - next.classList.add('active') + const blocks = group.querySelector('.blocks') + if (!blocks) return false - const label = group?.querySelector(`label[for="${el.id}"]`) - label?.scrollIntoView({ block: 'nearest' }) + // Update radio input checked state + inputs.forEach((radioInput, i) => { + radioInput.checked = i === index + }) + + // Remove active class from all blocks and add to the target block + Array.from(blocks.children).forEach((child, i) => { + child.classList.toggle('active', i === index) + }) + + return true +} + +function findTabByTitle( + group: HTMLElement, + tabTitle: string +): HTMLInputElement | null { + if (!tabTitle) return null + const labels = Array.from(group.querySelectorAll('label[data-title]')) + const targetLabel = labels.find( + (label) => + label.getAttribute('data-title')?.toLowerCase() === tabTitle.toLowerCase() + ) + + if (!targetLabel) return null + + const inputId = targetLabel.getAttribute('for') + if (!inputId) return null + + return group.querySelector(`#${inputId}`) +} + +function syncCodeGroupsByKeyAndValue( + groupKey: string, + tabValue: string, + excludeGroup?: HTMLElement +) { + const groups = getCodeGroupsByKey(groupKey) + + groups.forEach((group) => { + // Skip the group that was just clicked + if (excludeGroup && group === excludeGroup) return + + const input = findTabByTitle(group, tabValue) + if (input) { + activateTab(group, input) + } + }) +} + +function updateUrl(groupKey: string, tabValue: string) { + const url = new URL(window.location.href) + url.searchParams.set(groupKey, tabValue) + window.history.replaceState(null, '', url.toString()) +} + +function handleQueryParamNavigation() { + const urlParams = new URLSearchParams(window.location.search) + if (urlParams.size === 0) return + + for (const [groupKey, tabValue] of urlParams.entries()) { + const groups = getCodeGroupsByKey(groupKey) + + for (const group of groups) { + const input = findTabByTitle(group, tabValue) + if (input) { + activateTab(group, input) } - }) + } } } diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 39efce17fd2f..15c7012f4bb2 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -3,7 +3,7 @@ import container from 'markdown-it-container' import type { RenderRule } from 'markdown-it/lib/renderer.mjs' import type Token from 'markdown-it/lib/token.mjs' import type { MarkdownEnv } from '../../shared' -import { extractTitle } from './preWrapper' +import { extractTitle, extractCodeGroupKey } from './preWrapper' export const containerPlugin = ( md: MarkdownItAsync, @@ -64,6 +64,12 @@ function createCodeGroup(md: MarkdownItAsync): ContainerArgs { { render(tokens, idx) { if (tokens[idx].nesting === 1) { + // Extract the key from the code-group container info + const groupKey = extractCodeGroupKey(tokens[idx].info) + const groupKeyAttr = groupKey + ? ` data-group-key="${md.utils.escapeHtml(groupKey)}"` + : '' + let tabs = '' let checked = 'checked' @@ -87,7 +93,12 @@ function createCodeGroup(md: MarkdownItAsync): ContainerArgs { ) if (title) { - tabs += `` + // Use the group key for all tabs in this group + const keyAttr = groupKey + ? ` data-key="${md.utils.escapeHtml(groupKey)}"` + : '' + + tabs += `` if (checked && !isHtml) tokens[i].info += ' active' checked = '' @@ -95,7 +106,7 @@ function createCodeGroup(md: MarkdownItAsync): ContainerArgs { } } - return `
${tabs}
\n` + return `
${tabs}
\n` } return `
\n` } diff --git a/src/node/markdown/plugins/preWrapper.ts b/src/node/markdown/plugins/preWrapper.ts index e7d553b36368..c2b060e8485d 100644 --- a/src/node/markdown/plugins/preWrapper.ts +++ b/src/node/markdown/plugins/preWrapper.ts @@ -37,6 +37,13 @@ export function extractTitle(info: string, html = false) { return info.match(/\[(.*)\]/)?.[1] || extractLang(info) || 'txt' } +export function extractCodeGroupKey(info: string) { + // Extract key from code-group container info like ":package-manager", ":framework" etc. + const trimmedInfo = info.trim().replace(/^code-group\s*/, '') + const match = trimmedInfo.match(/^:([a-zA-Z0-9_-]+)/) + return match ? match[1] : null +} + function extractLang(info: string) { return info .trim()