diff --git a/src/index.ts b/src/index.ts index 27dcb42..ce07366 100755 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { Handler } from 'swup'; import Plugin from '@swup/plugin'; import mergeHeadContents from './mergeHeadContents.js'; -import updateLangAttribute from './updateLangAttribute.js'; +import updateAttributes from './updateAttributes.js'; import waitForAssets from './waitForAssets.js'; type Options = { @@ -12,6 +12,8 @@ type Options = { persistTags: boolean | string | ((el: Element) => boolean); /** Delay the transition to the new page until all newly added assets have finished loading. Default: `false` */ awaitAssets: boolean; + /** Additional attributes of the head element to update. Default: ['lang', 'dir']. */ + attributes: (string | RegExp)[]; /** How long to wait for assets before continuing anyway. Only applies if `awaitAssets` is enabled. Default: `3000` */ timeout: number; }; @@ -25,6 +27,7 @@ export default class SwupHeadPlugin extends Plugin { persistTags: false, persistAssets: false, awaitAssets: false, + attributes: ['lang', 'dir'], timeout: 3000 }; options: Options; @@ -45,6 +48,8 @@ export default class SwupHeadPlugin extends Plugin { } updateHead: Handler<'content:replace'> = async (visit, { page: { html } }) => { + const { awaitAssets, attributes, timeout } = this.options; + const newDocument = visit.to.document!; const { removed, added } = mergeHeadContents(document.head, newDocument.head, { @@ -53,13 +58,12 @@ export default class SwupHeadPlugin extends Plugin { this.swup.log(`Removed ${removed.length} / added ${added.length} tags in head`); - const lang = updateLangAttribute(document.documentElement, newDocument.documentElement); - if (lang) { - this.swup.log(`Updated lang attribute: ${lang}`); + if (attributes?.length) { + updateAttributes(document.documentElement, newDocument.documentElement, attributes); } - if (this.options.awaitAssets) { - const assetLoadPromises = waitForAssets(added, this.options.timeout); + if (awaitAssets) { + const assetLoadPromises = waitForAssets(added, timeout); if (assetLoadPromises.length) { this.swup.log(`Waiting for ${assetLoadPromises.length} assets to load`); await Promise.all(assetLoadPromises); diff --git a/src/updateAttributes.ts b/src/updateAttributes.ts new file mode 100755 index 0000000..82fd14a --- /dev/null +++ b/src/updateAttributes.ts @@ -0,0 +1,29 @@ +export default function updateAttributes( + target: Element, + source: Element, + filters: (string | RegExp)[] = [] +): void { + const keep = new Set(); + + for (const { name, value } of getAttributes(source, filters)) { + target.setAttribute(name, value); + keep.add(name); + } + + for (const { name } of getAttributes(target, filters)) { + if (!keep.has(name)) { + target.removeAttribute(name); + } + } +} + +function getAttributes(el: Element, filters: (string | RegExp)[] = []): Attr[] { + const all = Array.from(el.attributes); + if (!filters.length) return all; + + return all.filter(({ name }) => + filters.some((pattern) => + pattern instanceof RegExp ? pattern.test(name) : name === pattern + ) + ); +} diff --git a/src/updateLangAttribute.ts b/src/updateLangAttribute.ts deleted file mode 100644 index 0951371..0000000 --- a/src/updateLangAttribute.ts +++ /dev/null @@ -1,11 +0,0 @@ -export default function updateLangAttribute( - currentHtml: HTMLElement, - newHtml: HTMLElement -): string | null { - if (currentHtml.lang !== newHtml.lang) { - currentHtml.lang = newHtml.lang; - return currentHtml.lang || null; - } else { - return null; - } -} diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 4694bcd..a4e22ff 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -3,7 +3,7 @@ import Swup, { Visit } from 'swup'; import SwupHeadPlugin from '../../src/index.js'; vitest.mock('../../src/mergeHeadContents.js'); -vitest.mock('../../src/updateLangAttribute.js'); +vitest.mock('../../src/updateAttributes.js'); vitest.mock('../../src/waitForAssets.js'); const page = { page: { html: '', url: '/' } }; @@ -63,15 +63,17 @@ describe('SwupHeadPlugin', () => { }); it('updates lang attr from content:replace hook handler', async () => { - const updateLangAttribute = await import('../../src/updateLangAttribute.js'); - updateLangAttribute.default = vitest.fn().mockImplementation(() => 'fr'); + const updateAttributes = await import('../../src/updateAttributes.js'); + updateAttributes.default = vitest.fn().mockImplementation(() => 'fr'); swup.use(plugin); + plugin.options.attributes = ['lang', /^data-/]; plugin.updateHead(visit, page); - expect(updateLangAttribute.default).toHaveBeenCalledOnce(); - expect(updateLangAttribute.default).toHaveBeenCalledWith( + expect(updateAttributes.default).toHaveBeenCalledOnce(); + expect(updateAttributes.default).toHaveBeenCalledWith( document.documentElement, - visit.to.document!.documentElement + visit.to.document!.documentElement, + ['lang', /^data-/] ); }); diff --git a/tests/unit/updateAttributes.test.ts b/tests/unit/updateAttributes.test.ts new file mode 100644 index 0000000..13b764b --- /dev/null +++ b/tests/unit/updateAttributes.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import updateAttributes from '../../src/updateAttributes.js'; + +function createElement(html: string): Element { + const el = document.createElement('div'); + el.innerHTML = html; + return el.firstElementChild!; +}; + +const mergeAttributes = (currentEl: string, incomingEl: string, ...args): string => { + const current = createElement(currentEl); + const incoming = createElement(incomingEl); + updateAttributes(current, incoming, ...args); + return current.outerHTML; +}; + +describe('updateAttributes', () => { + describe('attributes', () => { + it('adds attributes', () => { + expect(mergeAttributes('
', '
')).toBe('
'); + expect(mergeAttributes('
', '
')).toBe('
'); + }); + + it('updates attributes', () => { + expect(mergeAttributes('
', '
')).toBe('
'); + }); + + it('removes attributes', () => { + expect(mergeAttributes('
', '
')).toBe('
'); + expect(mergeAttributes('
', '
')).toBe('
'); + }); + + it('allows filtering attributes', () => { + expect(mergeAttributes('
', '
', [''])).toBe('
'); + expect(mergeAttributes('
', '
', ['b'])).toBe('
'); + expect(mergeAttributes('
', '
', ['a'])).toBe('
'); + expect(mergeAttributes('
', '
', ['a'])).toBe('
'); + expect(mergeAttributes('
', '
', ['b'])).toBe('
'); + expect(mergeAttributes('
', '
', ['a', 'b'])).toBe('
'); + expect(mergeAttributes('
', '
', [/^ab/])).toBe('
'); + }); + + it('handles boolean attributes', () => { + expect(mergeAttributes('
', '')).toBe(''); + expect(mergeAttributes('
', '
')).toBe('
'); + }); + }); + + describe('types', () => { + const el = document.createElement('div'); + + it('returns nothing', () => { + expect(updateAttributes(el, el)).toBeUndefined(); + }); + + it('only accepts elements', () => { + try { + updateAttributes(el, el); + // @ts-expect-error + updateAttributes('', el); + // @ts-expect-error + updateAttributes(el, ''); + // @ts-expect-error + updateAttributes(el, 1); + // @ts-expect-error + updateAttributes(el, []); + // @ts-expect-error + updateAttributes(el); + } catch (error) {} + }); + }); +}); diff --git a/tests/unit/updateLangAttribute.test.ts b/tests/unit/updateLangAttribute.test.ts deleted file mode 100644 index 4151a9a..0000000 --- a/tests/unit/updateLangAttribute.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import updateLangAttribute from '../../src/updateLangAttribute.js'; - -describe('updateLangAttribute', () => { - it('updates the lang attribute when different', () => { - const currentHtml = document.createElement('html'); - const newHtml = document.createElement('html'); - currentHtml.lang = 'en'; - newHtml.lang = 'fr'; - - const result = updateLangAttribute(currentHtml, newHtml); - expect(result).toBe('fr'); - expect(currentHtml.lang).toBe('fr'); - }); - - it('does not update the lang attribute when identical', () => { - const currentHtml = document.createElement('html'); - const newHtml = document.createElement('html'); - currentHtml.lang = 'en'; - newHtml.lang = 'en'; - - const result = updateLangAttribute(currentHtml, newHtml); - expect(result).toBeNull(); - expect(currentHtml.lang).toBe('en'); - }); - - it('updates the lang attribute when current page has none', () => { - const currentHtml = document.createElement('html'); - const newHtml = document.createElement('html'); - newHtml.lang = 'fr'; - - const result = updateLangAttribute(currentHtml, newHtml); - expect(result).toBe('fr'); - expect(currentHtml.lang).toBe('fr'); - }); - - it('updates the lang attribute when new page has none', () => { - const currentHtml = document.createElement('html'); - const newHtml = document.createElement('html'); - currentHtml.lang = 'fr'; - - const result = updateLangAttribute(currentHtml, newHtml); - expect(result).toBeNull(); - expect(currentHtml.lang).toBe(''); - }); -});