diff --git a/__tests__/unit/shared/shared.test.ts b/__tests__/unit/shared/shared.test.ts new file mode 100644 index 00000000..77826db3 --- /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 8960bfe2..42f57f4a 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 a9d9c4f1..d79eb637 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 e7fda629..2b50151b 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] : ''