diff --git a/__tests__/e2e/markdown-extensions/index.md b/__tests__/e2e/markdown-extensions/index.md index c95d60e0..866197c1 100644 --- a/__tests__/e2e/markdown-extensions/index.md +++ b/__tests__/e2e/markdown-extensions/index.md @@ -132,6 +132,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 000ede05..98b02f13 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", @@ -215,6 +216,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 574e9626..b9a78ea4 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -745,6 +745,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 00000000..ec23ba34 --- /dev/null +++ b/patches/vitepress-plugin-group-icons.patch @@ -0,0 +1,23 @@ +diff --git a/dist/index.mjs b/dist/index.mjs +index 7146607f6eb82fe3e58634d0be5acfaa929cf526..a1cdfa79be761a0efc9880a35ec12016b7aea837 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -200,7 +200,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; +@@ -232,7 +232,8 @@ const HTTP_URL_RE = /^https?:\/\//; + 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 ec90881c..48eb76f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: esbuild: '-' patchedDependencies: + vitepress-plugin-group-icons: c379c3737bf09a62f52fdff21e0d71647ab2bdd813a34ac32a4d8aa1edca0e7f vue-sfc-transformer: 06dbce7d98fac77faf5e66a94aa6e04f34c0cc7ec0910ec10848e5f2e08b8b68 importers: @@ -308,7 +309,7 @@ importers: version: link:.. vitepress-plugin-group-icons: specifier: ^1.7.6 - version: 1.7.6(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)) + version: 1.7.6(patch_hash=c379c3737bf09a62f52fdff21e0d71647ab2bdd813a34ac32a4d8aa1edca0e7f)(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)) vitepress-plugin-llms: specifier: ^1.13.4 version: 1.13.4(supports-color@7.2.0) @@ -5082,7 +5083,7 @@ snapshots: jiti: 1.21.7 yaml: 2.9.0 - vitepress-plugin-group-icons@1.7.6(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)): + vitepress-plugin-group-icons@1.7.6(patch_hash=c379c3737bf09a62f52fdff21e0d71647ab2bdd813a34ac32a4d8aa1edca0e7f)(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)): dependencies: '@iconify-json/logos': 1.2.12 '@iconify-json/vscode-icons': 1.2.70 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7feb78ec..94ac8d26 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ overrides: patchedDependencies: vue-sfc-transformer: patches/vue-sfc-transformer.patch + vitepress-plugin-group-icons: patches/vitepress-plugin-group-icons.patch 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 a1d85d90..838abb84 100644 --- a/src/client/theme-default/styles/components/vp-doc.css +++ b/src/client/theme-default/styles/components/vp-doc.css @@ -495,6 +495,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*/ @@ -521,6 +547,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; @@ -581,6 +611,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 f95c80c3..dd9ef6ed 100644 --- a/src/client/theme-default/styles/vars.css +++ b/src/client/theme-default/styles/vars.css @@ -367,6 +367,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 690890ba..83d0c1ae 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -172,7 +172,7 @@ function createCodeGroupOpenRender(md: MarkdownItAsync): RenderRule { if ((tokens[i].type === 'fence' && tokens[i].tag === 'code') || isHtml) { 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 d5c66c7b..09b0ea24 100644 --- a/src/node/markdown/plugins/preWrapper.ts +++ b/src/node/markdown/plugins/preWrapper.ts @@ -1,5 +1,5 @@ import type { MarkdownItAsync } from 'markdown-it-async' - +import type Token from 'markdown-it/lib/token.mjs' import type { MarkdownEnv, MarkdownLocaleOptions } from '../../shared' export interface Options { @@ -22,7 +22,12 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) { const [tokens, idx, , env] = 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' : '' @@ -43,6 +48,11 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) { return ( `
` + + (title + ? `
` + + `${title}` + + `
` + : '') + `` + `${label}` + fence(...args) + @@ -51,13 +61,21 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) { } } -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): string { @@ -69,3 +87,18 @@ function extractLang(info: string): string { .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 +}