diff --git a/e2e/tests/site-data.cy.ts b/e2e/tests/site-data.cy.ts index cca6f83646..2ed1900b59 100644 --- a/e2e/tests/site-data.cy.ts +++ b/e2e/tests/site-data.cy.ts @@ -10,15 +10,15 @@ describe('siteData', () => { it('title', () => { cy.title().should('eq', 'VuePress E2E') - cy.get('head title').should('have.text', 'VuePress E2E') + cy.get('head title') + .should('have.length', 1) + .should('have.text', 'VuePress E2E') }) it('description', () => { - cy.get('head meta[name="description"]').should( - 'have.attr', - 'content', - 'VuePress E2E Test Site', - ) + cy.get('head meta[name="description"]') + .should('have.length', 1) + .should('have.attr', 'content', 'VuePress E2E Test Site') }) it('head', () => { @@ -48,15 +48,15 @@ describe('siteData', () => { it('title', () => { cy.title().should('eq', 'VuePress E2E') - cy.get('head title').should('have.text', 'VuePress E2E') + cy.get('head title') + .should('have.length', 1) + .should('have.text', 'VuePress E2E') }) it('description', () => { - cy.get('head meta[name="description"]').should( - 'have.attr', - 'content', - 'VuePress E2E 测试站点', - ) + cy.get('head meta[name="description"]') + .should('have.length', 1) + .should('have.attr', 'content', 'VuePress E2E 测试站点') }) it('head', () => { diff --git a/e2e/tests/update-head.cy.ts b/e2e/tests/update-head.cy.ts new file mode 100644 index 0000000000..0f617c8d37 --- /dev/null +++ b/e2e/tests/update-head.cy.ts @@ -0,0 +1,59 @@ +describe('updateHead', () => { + it('should update head correctly', () => { + // en-US + cy.visit('/') + + // lang + cy.get('html').should('have.attr', 'lang', 'en-US') + // title + cy.title().should('eq', 'VuePress E2E') + cy.get('head title') + .should('have.length', 1) + .should('have.text', 'VuePress E2E') + // description + cy.get('head meta[name="description"]') + .should('have.length', 1) + .should('have.attr', 'content', 'VuePress E2E Test Site') + // head + cy.get('head meta[name="foo"]') + .should('have.length', 1) + .should('have.attr', 'content', 'foo') + cy.get('head meta[name="bar"]') + .should('have.length', 1) + .should('have.attr', 'content', 'foobar') + cy.get('head meta[name="baz"]') + .should('have.length', 1) + .should('have.attr', 'content', 'foobar baz') + cy.get('head meta[name="foo-en"]') + .should('have.length', 1) + .should('have.attr', 'content', 'foo-en') + + // navigate to zh-CN + cy.get('.e2e-theme-nav a').contains('zh-CN').click() + + // lang + cy.get('html').should('have.attr', 'lang', 'zh-CN') + // title + cy.title().should('eq', 'VuePress E2E') + cy.get('head title') + .should('have.length', 1) + .should('have.text', 'VuePress E2E') + // description + cy.get('head meta[name="description"]') + .should('have.length', 1) + .should('have.attr', 'content', 'VuePress E2E 测试站点') + // head + cy.get('head meta[name="foo"]') + .should('have.length', 1) + .should('have.attr', 'content', 'foo') + cy.get('head meta[name="bar"]') + .should('have.length', 1) + .should('have.attr', 'content', 'foobar zh') + cy.get('head meta[name="baz"]') + .should('have.length', 1) + .should('have.attr', 'content', 'baz') + cy.get('head meta[name="foo-zh"]') + .should('have.length', 1) + .should('have.attr', 'content', 'foo-zh') + }) +}) diff --git a/packages/client/src/setupUpdateHead.ts b/packages/client/src/setupUpdateHead.ts index 504cc9240e..5757426c82 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,50 +25,86 @@ export const setupUpdateHead = (): void => { return } - const headTags = ref([]) + let managedHeadElements: HTMLElement[] = [] - // load current head tags from DOM - const loadHead = (): void => { + /** + * Take over the existing head elements + */ + const takeOverHeadElements = (): void => { head.value.forEach((item) => { - const tag = queryHeadTag(item) - if (tag) { - headTags.value.push(tag) + const headElement = queryHeadElement(item) + if (headElement) { + managedHeadElements.push(headElement) } }) } - // update html lang attribute and head tags to DOM + /** + * Generate head elements from current head config + */ + const generateHeadElements = (): HTMLElement[] => { + const result: HTMLElement[] = [] + head.value.forEach((item) => { + const headElement = createHeadElement(item) + if (headElement) { + result.push(headElement) + } + }) + return result + } + + /** + * Update head elements + */ const updateHead: UpdateHead = () => { + // set html lang attribute document.documentElement.lang = lang.value - headTags.value.forEach((item) => { - if (item.parentNode === document.head) { - document.head.removeChild(item) - } - }) - headTags.value.splice(0, headTags.value.length) + // generate new head elements from current head config + const newHeadElements = generateHeadElements() - head.value.forEach((item) => { - const tag = createHeadTag(item) - if (tag !== null) { - document.head.appendChild(tag) - headTags.value.push(tag) + // find the intersection of old and new head elements + managedHeadElements.forEach((oldEl, oldIndex) => { + const matchedIndex = newHeadElements.findIndex((newEl) => + oldEl.isEqualNode(newEl), + ) + // remove the non-intersection from old elements + if (matchedIndex === -1) { + oldEl.remove() + // use delete to make the index consistent + delete managedHeadElements[oldIndex] + } + // keep the intersection in old elements, and remove it from new elements + else { + // use splice to avoid empty item in next loop + newHeadElements.splice(matchedIndex, 1) } }) + // append the rest new elements to head + newHeadElements.forEach((el) => document.head.appendChild(el)) + // update managed head elements + managedHeadElements = [ + // filter out empty deleted items + ...managedHeadElements.filter((item) => !!item), + ...newHeadElements, + ] } provide(updateHeadSymbol, updateHead) onMounted(() => { - loadHead() - updateHead() - watch(() => head.value, updateHead) + // in production, the initial head elements are already pre-rendered, + // so we need to skip the first update and take over the existing elements. + if (!__VUEPRESS_DEV__) { + takeOverHeadElements() + } + watch(head, updateHead, { immediate: __VUEPRESS_DEV__ }) }) } /** - * Query the matched head tag of head config + * Query the matched head element of head config */ -export const queryHeadTag = ([ +export const queryHeadElement = ([ tagName, attrs, content = '', @@ -86,15 +122,19 @@ export const queryHeadTag = ([ .join('') const selector = `head > ${tagName}${attrsSelector}` - const tags = Array.from(document.querySelectorAll(selector)) - const matchedTag = tags.find((item) => item.innerText === content) - return matchedTag || null + const headElements = Array.from( + document.querySelectorAll(selector), + ) + const matchedHeadElement = headElements.find( + (item) => item.innerText === content, + ) + return matchedHeadElement || null } /** - * Create head tag from head config + * Create head element from head config */ -export const createHeadTag = ([ +export const createHeadElement = ([ tagName, attrs, content, @@ -104,23 +144,23 @@ export const createHeadTag = ([ } // create element - const tag = document.createElement(tagName) + const headElement = document.createElement(tagName) // set attributes if (isPlainObject(attrs)) { Object.entries(attrs).forEach(([key, value]) => { if (isString(value)) { - tag.setAttribute(key, value) + headElement.setAttribute(key, value) } else if (value === true) { - tag.setAttribute(key, '') + headElement.setAttribute(key, '') } }) } // set content if (isString(content)) { - tag.appendChild(document.createTextNode(content)) + headElement.appendChild(document.createTextNode(content)) } - return tag + return headElement } 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..2160862fd6 100644 --- a/packages/shared/src/utils/resolveHeadIdentifier.ts +++ b/packages/shared/src/utils/resolveHeadIdentifier.ts @@ -1,27 +1,46 @@ import type { HeadConfig } from '../types/index.js' +const TAGS_ALLOWED = ['link', 'meta', 'script', 'style', 'noscript', 'template'] +const TAGS_UNIQUE = ['title', 'base'] + /** * 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 unique tags + if (TAGS_UNIQUE.includes(tag)) { + return tag + } + + // avoid disallowed tags + if (!TAGS_ALLOWED.includes(tag)) { + return 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)) { - return tag - } - // avoid duplicated `<template>` with same `id` if (tag === 'template' && attrs.id) { return `${tag}.${attrs.id}` } - return JSON.stringify([tag, attrs, content]) + return JSON.stringify([ + tag, + Object.entries(attrs) + .map(([key, value]) => { + // normalize boolean attributes + if (typeof value === 'boolean') { + return value ? [key, ''] : null + } + return [key, value] + }) + .filter((item): item is [string, string] => item != null) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)), + content, + ]) } 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