From ed03db9a5970e4bd92d3f8d6ae26b244ea77928d Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:24:22 +0530 Subject: [PATCH 1/4] docs: remove unmaintained search plugins from list --- docs/en/reference/default-theme-search.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/en/reference/default-theme-search.md b/docs/en/reference/default-theme-search.md index 66bc0693d9c4..d383288e0902 100644 --- a/docs/en/reference/default-theme-search.md +++ b/docs/en/reference/default-theme-search.md @@ -27,11 +27,11 @@ Example result: Alternatively, you can use [Algolia DocSearch](#algolia-search) or some community plugins like: -- -- -- -- -- +- +- +- + + ### i18n {#local-search-i18n} From b18f30680b9bced83abd6eacb9417701298cb309 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:05:22 +0530 Subject: [PATCH 2/4] fix: key head entries by id and ignore meta content when deduping Any element with an `id` is keyed by it regardless of attribute order, and a `meta` without one is keyed by its first attribute other than `content`. This stops differently named meta tags with the same content from overriding each other and lets repeated meta tags be kept apart with unique ids. fixes #5362 closes #5363 closes #5379 Co-authored-by: Lazizbek Ergashev <20501725+lazerg@users.noreply.github.com> Co-authored-by: shamu45678 <220251922@seu.edu.cn> Co-Authored-By: Claude Fable 5 --- __tests__/unit/shared/shared.test.ts | 56 +++++++++++++++++++++++++ docs/en/reference/frontmatter-config.md | 2 +- docs/en/reference/site-config.md | 7 ++++ src/shared/shared.ts | 22 ++++++---- 4 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 __tests__/unit/shared/shared.test.ts diff --git a/__tests__/unit/shared/shared.test.ts b/__tests__/unit/shared/shared.test.ts new file mode 100644 index 000000000000..77826db31746 --- /dev/null +++ b/__tests__/unit/shared/shared.test.ts @@ -0,0 +1,56 @@ +import { mergeHead, type HeadConfig } from 'shared/shared' + +describe('shared/shared', () => { + describe('mergeHead', () => { + test('replaces meta tags with the same key in place', () => { + expect( + mergeHead( + [ + ['meta', { property: 'og:image', content: '/site.png' }], + ['meta', { name: 'keywords', content: 'site' }] + ], + [['meta', { content: '/page.png', property: 'og:image' }]] + ) + ).toEqual([ + ['meta', { content: '/page.png', property: 'og:image' }], + ['meta', { name: 'keywords', content: 'site' }] + ]) + }) + + test('ignores content when keying meta tags', () => { + const head: HeadConfig[] = [ + ['meta', { content: 'a', name: 'name1' }], + ['meta', { content: 'a', name: 'name2' }] + ] + expect(mergeHead(head)).toEqual(head) + }) + + test('keys any element by id regardless of attribute order', () => { + expect( + mergeHead( + [ + ['meta', { name: 'author', content: 'a', id: 'author-a' }], + ['meta', { name: 'author', content: 'b', id: 'author-b' }], + ['script', { id: 'sw' }, 'old'] + ], + [ + ['meta', { id: 'author-a', name: 'author', content: 'c' }], + ['script', { id: 'sw' }, 'new'] + ] + ) + ).toEqual([ + ['meta', { id: 'author-a', name: 'author', content: 'c' }], + ['meta', { name: 'author', content: 'b', id: 'author-b' }], + ['script', { id: 'sw' }, 'new'] + ]) + }) + + test('appends elements without a key', () => { + const head: HeadConfig[] = [ + ['link', { rel: 'stylesheet', href: '/a.css' }], + ['link', { rel: 'stylesheet', href: '/a.css' }] + ] + expect(mergeHead(head, head)).toEqual([...head, ...head]) + }) + }) +}) diff --git a/docs/en/reference/frontmatter-config.md b/docs/en/reference/frontmatter-config.md index 8960bfe23584..42f57f4a40a4 100644 --- a/docs/en/reference/frontmatter-config.md +++ b/docs/en/reference/frontmatter-config.md @@ -63,7 +63,7 @@ description: VitePress - Type: `HeadConfig[]` -Specify extra head tags to be injected for the current page. Will be appended after head tags injected by site-level config. +Specify extra head tags to be injected for the current page. They are [merged](./site-config#head) with the head tags injected by site-level config. ```yaml --- diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index a9d9c4f178fe..d79eb6378346 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -248,6 +248,13 @@ type HeadConfig = | [string, Record, string] ``` +Head entries from the site config, [locale config](../guide/i18n), [directory-level config](#directory-level-overrides), [frontmatter](./frontmatter-config#head) and [`transformHead`](#transformhead) are merged in that order. A later entry replaces an earlier one with the same key instead of being appended: + +- Any element with an `id` attribute is keyed by its `id`. +- A `meta` element without an `id` is keyed by its first attribute other than `content` (e.g. `name`, `property`, `http-equiv`) and that attribute's value. + +Other elements are never deduplicated. To render multiple `meta` tags that would share a key, like several ``, give each of them a unique `id`. + #### Example: Adding a favicon ```ts diff --git a/src/shared/shared.ts b/src/shared/shared.ts index e7fda629f946..2b50151b4ba5 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -198,25 +198,23 @@ function createTitleTemplate( export function mergeHead(...headArrays: HeadConfig[][]): HeadConfig[] { const merged: HeadConfig[] = [] - const metaKeyMap = new Map() + const keyMap = new Map() for (const current of headArrays) { for (const tag of current) { - const [type, attrs] = tag - const keyAttr = Object.entries(attrs)[0] + const key = getHeadKey(tag) - if (type !== 'meta' || !keyAttr) { + if (key == null) { merged.push(tag) continue } - const key = `${keyAttr[0]}=${keyAttr[1]}` - const existingIndex = metaKeyMap.get(key) + const existingIndex = keyMap.get(key) if (existingIndex != null) { merged[existingIndex] = tag // replace existing tag } else { - metaKeyMap.set(key, merged.length) + keyMap.set(key, merged.length) merged.push(tag) } } @@ -225,6 +223,16 @@ export function mergeHead(...headArrays: HeadConfig[][]): HeadConfig[] { return merged } +// any element is keyed by its `id`; a meta tag without one is keyed by its +// first attribute other than `content` (e.g. `name`, `property`, `http-equiv`) +function getHeadKey([type, attrs]: HeadConfig): string | undefined { + if (attrs.id) return `id=${attrs.id}` + if (type !== 'meta') return + for (const name in attrs) { + if (name !== 'content') return `${name}=${attrs[name]}` + } +} + export function sanitizeFileName(name: string): string { const match = DRIVE_LETTER_REGEX.exec(name) const driveLetter = match ? match[0] : '' From ed2bfb266ef788e86d1a73fe3cd7708a4cd5e260 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:24:45 +0530 Subject: [PATCH 3/4] fix(theme): render sidebar group toggles as native buttons The sidebar item row was a `role="button"` wrapping both the group heading and a second `role="button"` caret, which is invalid HTML and nests interactive controls. The caret is now the only control (a native button with `aria-expanded`), the row keeps its click handler as a mouse-only affordance, and groups without a heading render a `div` instead of a `section`. fixes #5366 closes #5371 Co-authored-by: Jibin7Jose Co-Authored-By: Claude Fable 5 --- __tests__/e2e/sidebar.test.ts | 36 ++++++++++++++ .../components/VPSidebarItem.vue | 49 ++++++------------- 2 files changed, 50 insertions(+), 35 deletions(-) create mode 100644 __tests__/e2e/sidebar.test.ts diff --git a/__tests__/e2e/sidebar.test.ts b/__tests__/e2e/sidebar.test.ts new file mode 100644 index 000000000000..288b15c83c49 --- /dev/null +++ b/__tests__/e2e/sidebar.test.ts @@ -0,0 +1,36 @@ +describe('sidebar', () => { + beforeAll(async () => { + await goto('/frontmatter/multiple-levels-outline') + }) + + test('collapsible group renders a heading and a single toggle button', async () => { + const group = page.locator('.VPSidebarItem.level-0.collapsible').first() + const caret = group.locator('.caret').first() + + expect(await page.locator('.VPSidebarItem [role="button"]').count()).toBe(0) + expect(await caret.evaluate((el) => el.tagName)).toBe('BUTTON') + expect(await caret.getAttribute('aria-expanded')).toBe('true') + }) + + test('group toggles with keyboard, caret and heading', async () => { + const group = page.locator('.VPSidebarItem.level-0.collapsible').first() + const caret = group.locator('.caret').first() + const isCollapsed = () => + group.evaluate((el) => el.classList.contains('collapsed')) + + await caret.focus() + await page.keyboard.press('Enter') + expect(await isCollapsed()).toBe(true) + expect(await caret.getAttribute('aria-expanded')).toBe('false') + + await page.keyboard.press('Space') + expect(await isCollapsed()).toBe(false) + expect(await caret.getAttribute('aria-expanded')).toBe('true') + + await caret.click() + expect(await isCollapsed()).toBe(true) + + await group.locator('.text').first().click() + expect(await isCollapsed()).toBe(false) + }) +}) diff --git a/src/client/theme-default/components/VPSidebarItem.vue b/src/client/theme-default/components/VPSidebarItem.vue index 6dad9f58e6b4..fe268a63ee5b 100644 --- a/src/client/theme-default/components/VPSidebarItem.vue +++ b/src/client/theme-default/components/VPSidebarItem.vue @@ -19,19 +19,16 @@ const { toggle } = useSidebarItemControl(computed(() => props.item)) -const sectionTag = computed(() => (hasChildren.value ? 'section' : `div`)) - const linkTag = computed(() => (isLink.value ? 'a' : 'div')) -const textTag = computed(() => { - return !hasChildren.value - ? 'p' - : props.depth + 2 === 7 - ? 'p' - : `h${props.depth + 2}` -}) +const textTag = computed(() => + hasChildren.value && props.depth < 5 ? `h${props.depth + 2}` : 'p' +) -const itemRole = computed(() => (isLink.value ? undefined : 'button')) +// a section needs a heading +const sectionTag = computed(() => + props.item.text && textTag.value !== 'p' ? 'section' : 'div' +) const classes = computed(() => [ [`level-${props.depth}`], @@ -42,31 +39,14 @@ const classes = computed(() => [ { 'has-active': hasActiveLink.value } ]) -function onItemInteraction(e: MouseEvent | Event) { - if ('key' in e && e.key !== 'Enter') { - return - } +function onItemClick() { !props.item.link && toggle() } - -function onCaretClick() { - props.item.link && toggle() -}