diff --git a/package.json b/package.json index 9effc81c..1c6dc6cc 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@quilted/typescript": "^0.4.2", "@quilted/vite": "^0.1.27", "@types/node": "~20.11.0", + "jest-extended": "^4.0.2", "jsdom": "^25.0.0", "prettier": "^3.3.3", "rollup": "^4.21.0", diff --git a/packages/polyfill/source/DocumentFragment.ts b/packages/polyfill/source/DocumentFragment.ts index 6f2e7a34..b5634938 100644 --- a/packages/polyfill/source/DocumentFragment.ts +++ b/packages/polyfill/source/DocumentFragment.ts @@ -1,8 +1,7 @@ -import {NAME, OWNER_DOCUMENT, NodeType} from './constants.ts'; +import {NAME, NodeType} from './constants.ts'; import {ParentNode} from './ParentNode.ts'; export class DocumentFragment extends ParentNode { nodeType = NodeType.DOCUMENT_FRAGMENT_NODE; [NAME] = '#document-fragment'; - [OWNER_DOCUMENT] = window.document as any; } diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index b3aed87d..6b7f8dac 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -50,7 +50,9 @@ export class ParentNode extends ChildNode { } removeChild(child: Node) { - if (child.parentNode !== this) throw Error(`not a child of this node`); + if (child[PARENT] !== this) throw Error(`not a child of this node`); + child[PARENT] = null; + const prev = child[PREV]; const next = child[NEXT]; if (prev) prev[NEXT] = next; diff --git a/packages/polyfill/source/serialization.ts b/packages/polyfill/source/serialization.ts index 9f8e174d..958237c5 100644 --- a/packages/polyfill/source/serialization.ts +++ b/packages/polyfill/source/serialization.ts @@ -13,19 +13,26 @@ import type {Comment} from './Comment.ts'; import type {ParentNode} from './ParentNode.ts'; import type {Element} from './Element.ts'; -// const voidElements = { -// img: true, -// image: true, -// }; -// const elementTokenizer = -// /(?:<([a-z][a-z0-9-:]*)( [^<>'"\n=\s]+=(['"])[^>'"\n]*\3)*\s*(\/?)\s*>|<\/([a-z][a-z0-9-:]*)>|([^&<>]+))/gi; -// const attributeTokenizer = / ([^<>'"\n=\s]+)=(['"])([^>'"\n]*)\2/g; +const ENTITIES = { + amp: '&', + quot: '"', + apos: "'", + lt: '<', + gt: '>', +} as const; + +function decode(str: string) { + return str.replace( + /&(?:(amp|quot|apos|lt|gt)|#(\d+));/gi, + (s, e: keyof typeof ENTITIES, d) => + d ? String.fromCharCode(d) : ENTITIES[e] || s, + ); +} const elementTokenizer = - /(?:<([a-z][a-z0-9-:]*)((?:\s[^<>'"=\n\s]+(?:=(['"])[^\n]*?\3|=[^>'"\n\s]*|))*)\s*(\/?)\s*>|<\/([a-z][a-z0-9-:]*)>||([^&<>]+))/gi; + /(?:<([a-z][a-z0-9-:]*)((?:\s+[^<>'"=\s]+(?:=(['"]).*?\3|=[^>'"\s]*|))*)\s*(\/?)\s*>|<\/([a-z][a-z0-9-:]*)>||([^<>]+))/gis; -const attributeTokenizer = - /\s([^<>'"=\n\s]+)(?:=(['"])([^\n]*?)\2|=([^>'"\n\s]*)|)/g; +const attributeTokenizer = /\s+([^<>'"=\s]+)(?:=(['"])(.*?)\2|=([^>'"\s]*)|)/gs; export function parseHtml(html: string, contextNode: Node) { const document = contextNode.ownerDocument; @@ -53,7 +60,13 @@ export function parseHtml(html: string, contextNode: Node) { } else if (token[6]) { parent.append(document.createComment(token[6]!)); } else { - parent.append(token[7]!); + const lastChild = parent.lastChild; + const text = decode(token[7]!); + if (lastChild && lastChild.nodeType === NodeType.TEXT_NODE) { + (lastChild as Text).data += text; + } else { + parent.append(text); + } } } return root; diff --git a/packages/polyfill/source/tests/Node.test.ts b/packages/polyfill/source/tests/Node.test.ts new file mode 100644 index 00000000..84976ddf --- /dev/null +++ b/packages/polyfill/source/tests/Node.test.ts @@ -0,0 +1,25 @@ +import {describe, it, expect} from 'vitest'; + +import {NAME} from '../constants'; +import {Node} from '../Node'; +import {createNode} from '../Document'; +import {setupScratch} from './helpers'; + +describe('Node', () => { + const {document, hooks} = setupScratch(); + + it('can be constructed', () => { + const node = createNode(new Node(), document); + expect(node.ownerDocument).toBe(document); + expect(node.isConnected).toBe(false); + expect(hooks.createText).not.toHaveBeenCalled(); + expect(hooks.insertChild).not.toHaveBeenCalled(); + }); + + it('exposes localName & nodeName', () => { + const node = createNode(new Node(), document); + node[NAME] = '#text'; + expect(node.localName).toBe('#text'); + expect(node.nodeName).toBe('#TEXT'); + }); +}); diff --git a/packages/polyfill/source/tests/ParentNode.test.ts b/packages/polyfill/source/tests/ParentNode.test.ts new file mode 100644 index 00000000..1e6595c3 --- /dev/null +++ b/packages/polyfill/source/tests/ParentNode.test.ts @@ -0,0 +1,177 @@ +import {describe, beforeEach, it, expect} from 'vitest'; + +import {Node} from '../Node'; +import {setupScratch} from './helpers'; + +describe('ParentNode', () => { + const ctx = setupScratch(); + const {hooks} = ctx; + + describe('append and remove', () => { + it('can be appended to a parent', () => { + const node = ctx.document.createTextNode(''); + ctx.scratch.append(node); + expect(ctx.scratch.childNodes).toEqual([node]); + expect(ctx.scratch.children).toEqual([]); + expect(hooks.insertChild).toHaveBeenCalledOnce(); + expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node, 0); + expect(node.parentNode).toBe(ctx.scratch); + expect(node.isConnected).toBe(true); + }); + + it('can be removed from a parent', () => { + const node = ctx.document.createTextNode(''); + ctx.scratch.append(node); + ctx.scratch.removeChild(node); + expect(ctx.scratch.childNodes).toEqual([]); + expect(hooks.insertChild).toHaveBeenCalledOnce(); + expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node, 0); + expect(hooks.removeChild).toHaveBeenCalledOnce(); + expect(hooks.removeChild).toHaveBeenCalledAfter(hooks.insertChild); + expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node, 0); + expect(node.parentNode).toBe(null); + expect(node.isConnected).toBe(false); + }); + + it('throws if removed from wrong parent', () => { + const node = ctx.document.createTextNode(''); + ctx.scratch.append(node); + expect(() => { + ctx.document.body.removeChild(node); + }).toThrow(); + expect(hooks.removeChild).not.toHaveBeenCalled(); + expect(node.parentNode).toBe(ctx.scratch); + expect(node.isConnected).toBe(true); + }); + + it('throws if removed twice', () => { + const node = ctx.document.createTextNode(''); + ctx.scratch.append(node); + ctx.scratch.removeChild(node); + ctx.clearMocks(); + expect(() => { + ctx.scratch.removeChild(node); + }).toThrow(); + expect(hooks.removeChild).not.toHaveBeenCalled(); + }); + }); + + describe('re-insertion into current parent', () => { + let node: Node; + let node2: Node; + let node3: Node; + beforeEach(() => { + node = ctx.document.createElement('node'); + node2 = ctx.document.createElement('node2'); + node3 = ctx.document.createElement('node3'); + ctx.scratch.append(node, node2, node3); + ctx.clearMocks(); + }); + + it('move to end', () => { + ctx.scratch.insertBefore(node, null); + expect(ctx.scratch.childNodes).toEqual([node2, node3, node]); + expect(ctx.scratch.children).toEqual([node2, node3, node]); + expect(hooks.removeChild).toHaveBeenCalledOnce(); + expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node, 0); + expect(hooks.insertChild).toHaveBeenCalledOnce(); + expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node, 2); + expect(hooks.insertChild).toHaveBeenCalledAfter(hooks.removeChild); + expect(node.parentNode).toBe(ctx.scratch); + expect(node.isConnected).toBe(true); + }); + + it('move to start', () => { + ctx.scratch.insertBefore(node3, node); + expect(ctx.scratch.childNodes).toEqual([node3, node, node2]); + expect(ctx.scratch.children).toEqual([node3, node, node2]); + expect(hooks.removeChild).toHaveBeenCalledOnce(); + expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node3, 2); + expect(hooks.insertChild).toHaveBeenCalledOnce(); + expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node3, 0); + expect(hooks.insertChild).toHaveBeenCalledAfter(hooks.removeChild); + expect(node.parentNode).toBe(ctx.scratch); + expect(node.isConnected).toBe(true); + }); + + it('reinsert at end', () => { + ctx.scratch.appendChild(node3); + expect(ctx.scratch.childNodes).toEqual([node, node2, node3]); + expect(ctx.scratch.children).toEqual([node, node2, node3]); + expect(hooks.removeChild).toHaveBeenCalledOnce(); + expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node3, 2); + expect(hooks.insertChild).toHaveBeenCalledOnce(); + expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node3, 2); + expect(hooks.insertChild).toHaveBeenCalledAfter(hooks.removeChild); + expect(node.parentNode).toBe(ctx.scratch); + expect(node.isConnected).toBe(true); + }); + + it('reinsert at start', () => { + ctx.scratch.insertBefore(node, node2); + expect(ctx.scratch.childNodes).toEqual([node, node2, node3]); + expect(ctx.scratch.children).toEqual([node, node2, node3]); + expect(hooks.removeChild).toHaveBeenCalledOnce(); + expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node, 0); + expect(hooks.insertChild).toHaveBeenCalledOnce(); + expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node, 0); + expect(hooks.insertChild).toHaveBeenCalledAfter(hooks.removeChild); + expect(node.parentNode).toBe(ctx.scratch); + expect(node.isConnected).toBe(true); + }); + + it('reverse children order', () => { + ctx.scratch.replaceChildren(node3, node2, node); + expect(ctx.scratch.childNodes).toEqual([node3, node2, node]); + expect(ctx.scratch.children).toEqual([node3, node2, node]); + // remove all nodes in document order + expect(hooks.removeChild).toHaveBeenCalledTimes(3); + expect(hooks.removeChild).toHaveBeenNthCalledWith( + 1, + ctx.scratch, + node, + 0, + ); + expect(hooks.removeChild).toHaveBeenNthCalledWith( + 2, + ctx.scratch, + node2, + 0, + ); + expect(hooks.removeChild).toHaveBeenNthCalledWith( + 3, + ctx.scratch, + node3, + 0, + ); + // removes should all be called prior to inserts + expect(hooks.removeChild).toHaveBeenCalledBefore(hooks.insertChild); + // insert all nodes in new order + expect(hooks.insertChild).toHaveBeenCalledTimes(3); + expect(hooks.insertChild).toHaveBeenNthCalledWith( + 1, + ctx.scratch, + node3, + 0, + ); + expect(hooks.insertChild).toHaveBeenNthCalledWith( + 2, + ctx.scratch, + node2, + 1, + ); + expect(hooks.insertChild).toHaveBeenNthCalledWith( + 3, + ctx.scratch, + node, + 2, + ); + expect(node.parentNode).toBe(ctx.scratch); + expect(node2.parentNode).toBe(ctx.scratch); + expect(node3.parentNode).toBe(ctx.scratch); + expect(node.isConnected).toBe(true); + expect(node2.isConnected).toBe(true); + expect(node3.isConnected).toBe(true); + }); + }); +}); diff --git a/packages/polyfill/source/tests/helpers.ts b/packages/polyfill/source/tests/helpers.ts new file mode 100644 index 00000000..9237db48 --- /dev/null +++ b/packages/polyfill/source/tests/helpers.ts @@ -0,0 +1,61 @@ +import {beforeAll, beforeEach, afterEach, vi, type Mock} from 'vitest'; +import {HOOKS} from '../constants'; +import {Window} from '../Window'; +import type {Element} from '../Element'; +import type {Document} from '../Document'; +import type {Hooks} from '../hooks'; + +export function setupScratch() { + let window!: Window; + let document!: Document; + let scratch!: Element; + const hooks = { + insertChild: vi.fn(), + createElement: vi.fn(), + createText: vi.fn(), + removeChild: vi.fn(), + setText: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setAttribute: vi.fn(), + removeAttribute: vi.fn(), + } satisfies {[key in keyof Hooks]: Mock}; + + function clearMocks() { + for (const key in hooks) { + hooks[key as keyof typeof hooks].mockClear(); + } + } + + beforeAll(() => { + window = new Window(); + window[HOOKS] = hooks; + document = window.document; + }); + + beforeEach(() => { + scratch = document.createElement('scratch'); + document.body.append(scratch); + clearMocks(); + }); + + afterEach(() => { + scratch.replaceChildren(); + scratch?.remove(); + }); + + return { + // using getters here is required because things are recreated for each test + get window() { + return window; + }, + get document() { + return document; + }, + get scratch() { + return scratch; + }, + hooks, + clearMocks, + }; +} diff --git a/packages/polyfill/source/tests/jest-extended.d.ts b/packages/polyfill/source/tests/jest-extended.d.ts new file mode 100644 index 00000000..c4aed589 --- /dev/null +++ b/packages/polyfill/source/tests/jest-extended.d.ts @@ -0,0 +1,8 @@ +import type CustomMatchers from 'jest-extended'; +import 'vitest'; + +declare module 'vitest' { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} + interface ExpectStatic extends CustomMatchers {} +} diff --git a/packages/polyfill/source/tests/serialization.test.ts b/packages/polyfill/source/tests/serialization.test.ts new file mode 100644 index 00000000..59920f39 --- /dev/null +++ b/packages/polyfill/source/tests/serialization.test.ts @@ -0,0 +1,161 @@ +import {describe, it, expect} from 'vitest'; + +import {Element} from '../Element'; +import {Text} from '../Text'; + +import {setupScratch} from './helpers'; + +describe('serialization', () => { + const ctx = setupScratch(); + + describe('set innerHTML', () => { + describe('plain text', () => { + it('handles plain text', () => { + ctx.scratch.innerHTML = 'foo'; + expect(ctx.scratch.childNodes).toHaveLength(1); + expect(ctx.scratch.childNodes[0]).toBeInstanceOf(Text); + expect(ctx.scratch.childNodes[0]).toHaveProperty('data', 'foo'); + }); + + it('handles encoded entities', () => { + ctx.scratch.innerHTML = 'foo&bar'; + expect(ctx.scratch.childNodes).toHaveLength(1); + expect(ctx.scratch.childNodes[0]).toBeInstanceOf(Text); + expect(ctx.scratch.childNodes[0]).toHaveProperty('data', 'foo&bar'); + }); + + it('handles non-encoded entities', () => { + ctx.scratch.innerHTML = 'foo&bar'; + expect(ctx.scratch.childNodes).toHaveLength(1); + expect(ctx.scratch.childNodes[0]).toHaveProperty('data', 'foo&bar'); + }); + }); + + it('parses single tags', () => { + ctx.scratch.innerHTML = ''; + expect(ctx.scratch.childNodes).toHaveLength(1); + expect(ctx.scratch.childNodes[0]).toBeInstanceOf(Element); + expect(ctx.scratch.childNodes[0]).toHaveProperty('localName', 'a'); + }); + + it('parses text children', () => { + ctx.scratch.innerHTML = 'foo'; + expect(ctx.scratch.childNodes).toHaveLength(1); + const child = ctx.scratch.firstChild as Element; + expect(child).toBeInstanceOf(Element); + expect(child.childNodes).toHaveLength(1); + expect(child.firstChild).toBeInstanceOf(Text); + expect(child.firstChild).toHaveProperty('data', 'foo'); + }); + + it('parses attributes', () => { + ctx.scratch.innerHTML = ''; + expect(ctx.scratch.childNodes).toHaveLength(1); + const child = ctx.scratch.firstChild as Element; + expect(child.attributes).toHaveLength(1); + expect(child.attributes.item(0)).toEqual( + expect.objectContaining({name: 'href', value: '/link'}), + ); + }); + + it('parses multiple attributes', () => { + ctx.scratch.innerHTML = ''; + expect(ctx.scratch.childNodes).toHaveLength(1); + const child = ctx.scratch.firstChild as Element; + expect(child.attributes).toHaveLength(2); + expect(child.attributes.item(0)).toEqual( + expect.objectContaining({name: 'href', value: '/link'}), + ); + expect(child.attributes.item(1)).toEqual( + expect.objectContaining({name: 'class', value: 'test'}), + ); + }); + + it('parses boolean attributes', () => { + ctx.scratch.innerHTML = ''; + expect(ctx.scratch.childNodes).toHaveLength(1); + const child = ctx.scratch.firstChild as Element; + expect(child.attributes).toHaveLength(1); + expect(child.getAttribute('disabled')).toBe(''); + }); + + it('parses unquoted attributes', () => { + // href value is testing that the slash isn't interpreted as a self-closing tag + ctx.scratch.innerHTML = ''; + expect(ctx.scratch.childNodes).toHaveLength(1); + const child = ctx.scratch.firstChild as Element; + expect(child.attributes).toHaveLength(2); + expect(child.attributes.item(0)).toEqual( + expect.objectContaining({name: 'class', value: 'test'}), + ); + expect(child.attributes.item(1)).toEqual( + expect.objectContaining({name: 'href', value: 'foo/'}), + ); + }); + + it('closes unclosed tags', () => { + ctx.scratch.innerHTML = 'foo'; + expect(ctx.scratch.childNodes).toHaveLength(1); + const child = ctx.scratch.firstChild as Element; + expect(child.attributes).toHaveLength(1); + expect(child.getAttribute('href')).toBe('foo'); + expect(child.childNodes).toHaveLength(1); + expect(child.firstChild).toBeInstanceOf(Text); + expect(child.firstChild).toHaveProperty('data', 'foo'); + }); + + // it('closes mismatched tags', () => { + // ctx.scratch.innerHTML = 'fooafter'; + // expect(ctx.scratch.childNodes).toHaveLength(2); + // const child = ctx.scratch.firstChild as Element; + // expect(child).toHaveProperty('localName', 'a'); + // expect(child.childNodes).toHaveLength(1); + // expect(child.firstChild).toHaveProperty('localName', 'b'); + // expect(child.firstChild!.childNodes).toHaveLength(1); + // expect(child.firstChild!.firstChild).toHaveProperty('data', 'foo'); + // expect(ctx.scratch.lastChild).toHaveProperty('data', 'after'); + // }); + }); + + describe('textContent', () => { + describe('get', () => { + it('returns the text content of the node', () => { + ctx.scratch.append('some text'); + expect(ctx.scratch.textContent).toBe('some text'); + }); + + it('returns the text content including descendants', () => { + const span = ctx.document.createElement('span'); + span.append('content'); + ctx.scratch.append('before', span, 'after'); + expect(ctx.scratch.textContent).toBe('beforecontentafter'); + }); + }); + + describe('set', () => { + it('inserts a Text node when called on empty element', () => { + ctx.scratch.textContent = 'some text'; + expect(ctx.scratch.childNodes).toHaveLength(1); + expect(ctx.scratch.childNodes[0]).toBeInstanceOf(Text); + expect(ctx.scratch.childNodes[0]).toHaveProperty('data', 'some text'); + }); + + it('replaces children when called on non-empty element', () => { + ctx.scratch.append( + ctx.document.createElement('span'), + ctx.document.createElement('div'), + ); + ctx.scratch.textContent = 'some text'; + expect(ctx.scratch.childNodes).toHaveLength(1); + expect(ctx.scratch.children).toHaveLength(0); + expect(ctx.scratch.childNodes[0]).toBeInstanceOf(Text); + expect(ctx.scratch.childNodes[0]).toHaveProperty('data', 'some text'); + }); + + it('does not decode entities', () => { + ctx.scratch.textContent = 'foo&bar'; + expect(ctx.scratch.childNodes[0]).toHaveProperty('data', 'foo&bar'); + }); + }); + }); +}); diff --git a/packages/polyfill/source/tests/setup.ts b/packages/polyfill/source/tests/setup.ts new file mode 100644 index 00000000..4d75466b --- /dev/null +++ b/packages/polyfill/source/tests/setup.ts @@ -0,0 +1,4 @@ +import {expect} from 'vitest'; +import * as matchers from 'jest-extended'; + +expect.extend(matchers); diff --git a/packages/polyfill/vitest.config.ts b/packages/polyfill/vitest.config.ts new file mode 100644 index 00000000..42014a38 --- /dev/null +++ b/packages/polyfill/vitest.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + setupFiles: ['./source/tests/setup.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc607706..1ada341e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@types/node': specifier: ~20.11.0 version: 20.11.16 + jest-extended: + specifier: ^4.0.2 + version: 4.0.2 jsdom: specifier: ^25.0.0 version: 25.0.0 @@ -4310,11 +4313,39 @@ packages: pretty-format: 27.5.1 dev: true + /jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + dev: true + + /jest-extended@4.0.2: + resolution: {integrity: sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + jest: '>=27.2.5' + peerDependenciesMeta: + jest: + optional: true + dependencies: + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + dev: true + /jest-get-type@27.5.1: resolution: {integrity: sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dev: true + /jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + /jest-matcher-utils@27.5.1: resolution: {integrity: sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}