diff --git a/__tests__/e2e/markdown-extensions/index.md b/__tests__/e2e/markdown-extensions/index.md index 3446b4efdb1e..252df395fb13 100644 --- a/__tests__/e2e/markdown-extensions/index.md +++ b/__tests__/e2e/markdown-extensions/index.md @@ -120,6 +120,12 @@ const line1 = 'This is line 1' const line2 = 'This is line 2' ``` +## Title Bar + +```js [main.js] +console.log('Hello, VitePress!') +``` + ## Import Code Snippets ### Basic Code Snippet diff --git a/__tests__/e2e/markdown-extensions/markdown-extensions.test.ts b/__tests__/e2e/markdown-extensions/markdown-extensions.test.ts index 839f953cba9e..7e7e23cdefbf 100644 --- a/__tests__/e2e/markdown-extensions/markdown-extensions.test.ts +++ b/__tests__/e2e/markdown-extensions/markdown-extensions.test.ts @@ -84,6 +84,7 @@ describe('Table of Contents', () => { "Multiple single lines, ranges", "Comment Highlight", "Line Numbers", + "Title Bar", "Import Code Snippets", "Basic Code Snippet", "Specify Region", @@ -213,6 +214,16 @@ describe('Line Numbers', () => { }) }) +describe('Title bar', () => { + test('render title bar', async () => { + const div = page.locator('#title-bar + div') + const titleBar = div.locator('.title-bar') + expect(titleBar).toBeTruthy() + const titleText = titleBar.locator('.title-text') + expect(await titleText.textContent()).toBe('main.js') + }) +}) + describe('Import Code Snippets', () => { test('basic', async () => { const lines = page.locator('#basic-code-snippet + div code > span') diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index e1be3e6b366f..0f9344cf0ad8 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -619,6 +619,24 @@ const line3 = 'This is line 3' const line4 = 'This is line 4' ``` +## Title Bar + +You can add a title bar to your code blocks by adding `[title]` in the fenced code block: + +**Input** + +````md +```js [main.js] +console.log('Hello, VitePress!') +``` +```` + +**Output** + +```js [main.js] +console.log('Hello, VitePress!') +``` + ## Import Code Snippets You can import code snippets from existing files via following syntax: diff --git a/patches/vitepress-plugin-group-icons.patch b/patches/vitepress-plugin-group-icons.patch new file mode 100644 index 000000000000..591b00cfece2 --- /dev/null +++ b/patches/vitepress-plugin-group-icons.patch @@ -0,0 +1,23 @@ +diff --git a/dist/index.js b/dist/index.js +index d607382406f19def4a6a6472c6143ff56f1db205..ed525461280b730d40be93291d17fa05477eb24b 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -101,7 +101,7 @@ function groupIconMdPlugin(md, options) { + }); + }; + const fenceRule = md.renderer.rules.fence; +- if (fenceRule) md.renderer.rules.fence = (...args) => { ++ if (false) md.renderer.rules.fence = (...args) => { + const [tokens, idx] = args; + const token = tokens[idx]; + let isOnCodeGroup = false; +@@ -133,7 +133,8 @@ function groupIconMdPlugin(md, options) { + async function generateCSS(labels, options) { + const baseCSS = ` + .vp-code-block-title [data-title]::before, +-.vp-code-group [data-title]::before { ++.vp-code-group [data-title]::before, ++.vp-doc [class*='language-'] [data-title]::before { + display: inline-block; + width: 1em; + height: 1em; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6f9f1eeda54..2aafb5bcdcc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ patchedDependencies: markdown-it-anchor@9.2.0: hash: cdc28e7c329be30688ad192126ba505446611fbe526ad51483e4b1287aa35cf9 path: patches/markdown-it-anchor@9.2.0.patch + vitepress-plugin-group-icons: + hash: 771e35a6827c2547da230d52d3b7032b1d1d75926162c95384f77e5528659991 + path: patches/vitepress-plugin-group-icons.patch importers: @@ -335,7 +338,7 @@ importers: version: link:.. vitepress-plugin-group-icons: specifier: ^1.6.3 - version: 1.6.3(@types/node@24.3.0)(esbuild@0.25.9)(jiti@1.21.7)(markdown-it@14.1.0)(yaml@2.8.1) + version: 1.6.3(patch_hash=771e35a6827c2547da230d52d3b7032b1d1d75926162c95384f77e5528659991)(@types/node@24.3.0)(esbuild@0.25.9)(jiti@1.21.7)(markdown-it@14.1.0)(yaml@2.8.1) vitepress-plugin-llms: specifier: ^1.7.3 version: 1.7.3 @@ -6011,7 +6014,7 @@ snapshots: - tsx - yaml - vitepress-plugin-group-icons@1.6.3(@types/node@24.3.0)(esbuild@0.25.9)(jiti@1.21.7)(markdown-it@14.1.0)(yaml@2.8.1): + vitepress-plugin-group-icons@1.6.3(patch_hash=771e35a6827c2547da230d52d3b7032b1d1d75926162c95384f77e5528659991)(@types/node@24.3.0)(esbuild@0.25.9)(jiti@1.21.7)(markdown-it@14.1.0)(yaml@2.8.1): dependencies: '@iconify-json/logos': 1.2.9 '@iconify-json/vscode-icons': 1.2.30 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eb3783b5dd06..a75761ba41fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ overrides: patchedDependencies: '@types/mdurl@2.0.0': patches/@types__mdurl@2.0.0.patch markdown-it-anchor@9.2.0: patches/markdown-it-anchor@9.2.0.patch + vitepress-plugin-group-icons: patches/vitepress-plugin-group-icons.patch autoInstallPeers: false shellEmulator: true diff --git a/src/client/theme-default/styles/components/vp-doc.css b/src/client/theme-default/styles/components/vp-doc.css index abc58243eddb..a5be63683f41 100644 --- a/src/client/theme-default/styles/components/vp-doc.css +++ b/src/client/theme-default/styles/components/vp-doc.css @@ -426,6 +426,32 @@ border-color 0.5s, color 0.5s; } +.vp-doc [class*='language-'] > .title-bar { + position: relative; + display: flex; + margin-right: -24px; + margin-left: -24px; + padding: 0 12px; + background-color: var(--vp-code-block-title-bg); + box-shadow: inset 0 -1px var(--vp-code-block-divider-color); +} + +@media (min-width: 640px) { + .vp-doc [class*='language-'] > .title-bar { + margin-right: 0; + margin-left: 0; + border-radius: 8px 8px 0 0; + } +} + +.vp-doc [class*='language-'] > .title-bar > .title-text { + padding: 0 12px; + line-height: 48px; + font-size: 14px; + font-weight: 500; + color: var(--vp-code-block-title-color); + white-space: nowrap; +} .vp-doc [class*='language-'] > button.copy { /*rtl:ignore*/ @@ -452,6 +478,10 @@ opacity 0.25s; } +.vp-doc [class*='language-'] > .title-bar ~ button.copy { + top: 60px; +} + .vp-doc [class*='language-']:hover > button.copy, .vp-doc [class*='language-'] > button.copy:focus { opacity: 1; @@ -512,6 +542,10 @@ opacity 0.4s; } +.vp-doc [class*='language-'] > .title-bar ~ span.lang { + top: 50px; +} + .vp-doc [class*='language-']:hover > button.copy + span.lang, .vp-doc [class*='language-'] > button.copy:focus + span.lang { opacity: 0; diff --git a/src/client/theme-default/styles/vars.css b/src/client/theme-default/styles/vars.css index 2a974f25eee3..48502a4029e2 100644 --- a/src/client/theme-default/styles/vars.css +++ b/src/client/theme-default/styles/vars.css @@ -339,6 +339,8 @@ --vp-code-block-color: var(--vp-c-text-2); --vp-code-block-bg: var(--vp-c-bg-alt); --vp-code-block-divider-color: var(--vp-c-gutter); + --vp-code-block-title-color: var(--vp-c-text-2); + --vp-code-block-title-bg: var(--vp-code-block-bg); --vp-code-lang-color: var(--vp-c-text-2); diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 39efce17fd2f..831b1db63cab 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -83,7 +83,7 @@ function createCodeGroup(md: MarkdownItAsync): ContainerArgs { ) { const title = extractTitle( isHtml ? tokens[i].content : tokens[i].info, - isHtml + { html: isHtml, fallbackToLang: true } ) if (title) { diff --git a/src/node/markdown/plugins/preWrapper.ts b/src/node/markdown/plugins/preWrapper.ts index b75fa6188d3f..2e62b42118e2 100644 --- a/src/node/markdown/plugins/preWrapper.ts +++ b/src/node/markdown/plugins/preWrapper.ts @@ -1,4 +1,5 @@ import type { MarkdownItAsync } from 'markdown-it-async' +import type Token from 'markdown-it/lib/token.mjs' export interface Options { codeCopyButtonTitle: string @@ -16,7 +17,12 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) { const [tokens, idx] = args const token = tokens[idx] - // remove title from info + // @ts-ignore + const isFromSnippet = !!token.src + const title = + isFromSnippet || isInCodeGroup(tokens, idx) + ? '' + : extractTitle(token.info) token.info = token.info.replace(/\[.*\]/, '') const active = / active( |$)/.test(token.info) ? ' active' : '' @@ -27,21 +33,34 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) { return ( `
` + + (title + ? `
` + + `${title}` + + `
` + : '') + `` + `${label}` + fence(...args) + - '
' + `` ) } } -export function extractTitle(info: string, html = false) { - if (html) { +export interface ExtractTitleOptions { + html?: boolean + fallbackToLang?: boolean +} + +export function extractTitle(info: string, options?: ExtractTitleOptions) { + if (options?.html) { return ( info.replace(//g, '').match(/data-title="(.*?)"/)?.[1] || '' ) } - return info.match(/\[(.*)\]/)?.[1] || extractLang(info) || 'txt' + return ( + info.match(/\[(.*)\]/)?.[1] || + (options?.fallbackToLang ? extractLang(info) || 'txt' : '') + ) } function extractLang(info: string) { @@ -53,3 +72,18 @@ function extractLang(info: string) { .replace(/^vue-html$/, 'template') .replace(/^ansi$/, '') } + +/** + * Whether the `idx` within `tokens` is inside a code-group container + */ +function isInCodeGroup(tokens: Token[], idx: number): boolean { + for (let i = idx - 1; i >= 0; --i) { + if (tokens[i].type === 'container_code-group_open') { + return true + } + if (tokens[i].type === 'container_code-group_close') { + return false + } + } + return false +}