diff --git a/packages/client/src/setupUpdateHead.ts b/packages/client/src/setupUpdateHead.ts index 504cc9240e..dcf8f7ef9c 100644 --- a/packages/client/src/setupUpdateHead.ts +++ b/packages/client/src/setupUpdateHead.ts @@ -1,6 +1,6 @@ import { isPlainObject, isString } from '@vuepress/shared' import type { HeadConfig, VuepressSSRContext } from '@vuepress/shared' -import { onMounted, provide, ref, useSSRContext, watch } from 'vue' +import { onMounted, provide, useSSRContext, watch } from 'vue' import { updateHeadSymbol, usePageHead, @@ -25,42 +25,51 @@ export const setupUpdateHead = (): void => { return } - const headTags = ref([]) + const managedHeadElements: HTMLElement[] = [] // load current head tags from DOM const loadHead = (): void => { - head.value.forEach((item) => { - const tag = queryHeadTag(item) - if (tag) { - headTags.value.push(tag) - } - }) + managedHeadElements.push( + ...(head.value.map(queryHeadElement).filter(Boolean) as HTMLElement[]), + ) } // update html lang attribute and head tags to DOM const updateHead: UpdateHead = () => { document.documentElement.lang = lang.value - headTags.value.forEach((item) => { + managedHeadElements.forEach((item) => { if (item.parentNode === document.head) { document.head.removeChild(item) } }) - headTags.value.splice(0, headTags.value.length) - head.value.forEach((item) => { - const tag = createHeadTag(item) - if (tag !== null) { - document.head.appendChild(tag) - headTags.value.push(tag) + const newHeadElements = head.value + .map(createHeadElement) + .filter(Boolean) as HTMLElement[] + + managedHeadElements.forEach((el, index) => { + const matchedIndex = newHeadElements.findIndex( + (newEl) => newEl?.isEqualNode(el ?? null), + ) + + if (matchedIndex !== -1) { + delete newHeadElements[matchedIndex] + } else { + el?.remove() + delete managedHeadElements[index] } }) + + newHeadElements.forEach((el) => { + document.head.appendChild(el) + }) + managedHeadElements.push(...newHeadElements) } provide(updateHeadSymbol, updateHead) onMounted(() => { loadHead() - updateHead() watch(() => head.value, updateHead) }) } @@ -68,7 +77,7 @@ export const setupUpdateHead = (): void => { /** * Query the matched head tag of head config */ -export const queryHeadTag = ([ +export const queryHeadElement = ([ tagName, attrs, content = '', @@ -94,7 +103,7 @@ export const queryHeadTag = ([ /** * Create head tag from head config */ -export const createHeadTag = ([ +export const createHeadElement = ([ tagName, attrs, content, diff --git a/packages/shared/src/utils/dedupeHead.ts b/packages/shared/src/utils/dedupeHead.ts index 06dc30c77d..3b47a72a4c 100644 --- a/packages/shared/src/utils/dedupeHead.ts +++ b/packages/shared/src/utils/dedupeHead.ts @@ -12,7 +12,7 @@ export const dedupeHead = (head: HeadConfig[]): HeadConfig[] => { head.forEach((item) => { const identifier = resolveHeadIdentifier(item) - if (!identifierSet.has(identifier)) { + if (identifier && !identifierSet.has(identifier)) { identifierSet.add(identifier) result.push(item) } diff --git a/packages/shared/src/utils/resolveHeadIdentifier.ts b/packages/shared/src/utils/resolveHeadIdentifier.ts index fff7dc7bbc..7d011ffad6 100644 --- a/packages/shared/src/utils/resolveHeadIdentifier.ts +++ b/packages/shared/src/utils/resolveHeadIdentifier.ts @@ -1,20 +1,21 @@ import type { HeadConfig } from '../types/index.js' +const onlyTags = ['title', 'base'] +const allowedTags = ['link', 'meta', 'script', 'style', 'noscript', 'template'] + /** * Resolve identifier of a tag, to avoid duplicated tags in `` */ -export const resolveHeadIdentifier = ([ - tag, - attrs, - content, -]: HeadConfig): string => { +export const resolveHeadIdentifier = ([tag, attrs, content]: HeadConfig): + | string + | null => { // avoid duplicated `` with same `name` if (tag === 'meta' && attrs.name) { return `${tag}.${attrs.name}` } // there should be only one `` or `<base>` - if (['title', 'base'].includes(tag)) { + if (onlyTags.includes(tag)) { return tag } @@ -23,5 +24,27 @@ export const resolveHeadIdentifier = ([ return `${tag}.${attrs.id}` } - return JSON.stringify([tag, attrs, content]) + if (allowedTags.includes(tag)) { + return JSON.stringify([ + tag, + Object.fromEntries( + Object.entries(attrs) + // handle boolean attributes + .map(([key, value]) => + typeof value === 'boolean' + ? value + ? [key, ''] + : null + : [key, value], + ) + .filter((item): item is [string, string] => item != null) + // sort keys + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)), + ), + content, + ]) + } + + // tags are not allowed + return null } diff --git a/packages/shared/tests/resolveHeadIdentifier.spec.ts b/packages/shared/tests/resolveHeadIdentifier.spec.ts index 6e169b0943..db0a18916b 100644 --- a/packages/shared/tests/resolveHeadIdentifier.spec.ts +++ b/packages/shared/tests/resolveHeadIdentifier.spec.ts @@ -60,6 +60,54 @@ describe('shared > resolveHeadIdentifier', () => { expect(templateFooBaz).not.toBe(templateBarBar) }) + it('should resolve same identifiers of same HeadConfig', () => { + const style1 = resolveHeadIdentifier([ + 'style', + { id: 'foo' }, + `body { color: red; }`, + ]) + const style2 = resolveHeadIdentifier([ + 'style', + { id: 'foo' }, + `body { color: red; }`, + ]) + const link1 = resolveHeadIdentifier([ + 'link', + { href: 'https://example.com', defer: '' }, + ]) + const link2 = resolveHeadIdentifier([ + 'link', + { href: 'https://example.com', defer: true }, + ]) + const link3 = resolveHeadIdentifier([ + 'link', + { defer: '', href: 'https://example.com' }, + ]) + const link4 = resolveHeadIdentifier([ + 'link', + { defer: true, href: 'https://example.com' }, + ]) + const link5 = resolveHeadIdentifier([ + 'link', + { href: 'https://example.com' }, + ]) + const link6 = resolveHeadIdentifier([ + 'link', + { href: 'https://example.com', defer: false }, + ]) + const link7 = resolveHeadIdentifier([ + 'link', + { defer: false, href: 'https://example.com' }, + ]) + + expect(style1).toBe(style2) + expect(link1).toBe(link2) + expect(link1).toBe(link3) + expect(link1).toBe(link4) + expect(link5).toBe(link6) + expect(link5).toBe(link7) + }) + it('should resolve identifiers correctly', () => { const head: HeadConfig[] = [ // 1