From 8a2671fa0d84e6ddab2904df7c1ea3676810f571 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Thu, 18 Apr 2024 23:15:56 +0300 Subject: [PATCH 1/2] feat: add DOM level 4 child element properties This adds the properties childElementCount, firstElementChild, lastElementChild, nextElementSibling and previousElementSibling to Element. The first three are also added to Document and DocumentFragment. --- lib/dom.js | 119 +++++++++++++++++++++++++++- readme.md | 20 ++++- test/dom/dom-implementation.test.js | 38 ++++----- test/dom/element.test.js | 45 ++++++++--- test/parse/node.test.js | 19 ++++- 5 files changed, 203 insertions(+), 38 deletions(-) diff --git a/lib/dom.js b/lib/dom.js index a73076f76..05d58cacc 100644 --- a/lib/dom.js +++ b/lib/dom.js @@ -1623,10 +1623,14 @@ function _onRemoveAttribute(doc, el, newAttr, remove) { } /** - * Updates `parent.childNodes`, adjusting the indexed items and its `length`. + * Updates `parent.firstElementChild`, `parent.lastElementChild` and `parent.childNodes`, + * adjusting the indexed items and its `length`. + * Also updates `nextElementSibling` and `previousElementSibling` references of the child + * nodes. * If `newChild` is provided and has no nextSibling, it will be appended. * Otherwise, it's assumed that an item has been removed or inserted, - * and `parent.firstNode` and its `.nextSibling` to re-indexing all child nodes of `parent`. + * and `parent.firstNode` and its `.nextSibling` are used to re-index all child nodes of + * `parent`. * * @param {Document} doc * The parent document of `el`. @@ -1645,13 +1649,39 @@ function _onUpdateChild(doc, parent, newChild) { if (newChild && !newChild.nextSibling) { // if an item has been appended, we only need to update the last index and the length childNodes[childNodes.length++] = newChild; + if (isElementNode(newChild)) { + newChild.previousElementSibling = parent.lastElementChild; + newChild.nextElementSibling = null; + parent.childElementCount++; + if (!parent.firstElementChild) { + parent.firstElementChild = newChild; + } + if (parent.lastElementChild) { + parent.lastElementChild.nextElementSibling = newChild; + } + parent.lastElementChild = newChild; + } } else { // otherwise we need to reindex all items, // which can take a while when processing nodes with a lot of children + parent.childElementCount = 0; + parent.firstElementChild = parent.lastElementChild = null; var child = parent.firstChild; var i = 0; while (child) { childNodes[i++] = child; + if (isElementNode(child)) { + child.previousElementSibling = parent.lastElementChild; + child.nextElementSibling = null; + parent.childElementCount++; + if (!parent.firstElementChild) { + parent.firstElementChild = child; + } + if (parent.lastElementChild) { + parent.lastElementChild.nextElementSibling = child; + } + parent.lastElementChild = child; + } child = child.nextSibling; } childNodes.length = i; @@ -1697,6 +1727,10 @@ function _removeChild(parentNode, child) { child.parentNode = null; child.previousSibling = null; child.nextSibling = null; + if (isElementNode(child)) { + child.previousElementSibling = null; + child.nextElementSibling = null; + } return child; } @@ -2056,7 +2090,7 @@ function _insertBefore(parent, node, child, _inDocumentAssertion) { do { newFirst.parentNode = parent; } while (newFirst !== newLast && (newFirst = newFirst.nextSibling)); - _onUpdateChild(parent.ownerDocument || parent, parent, node); + _onUpdateChild(parent.ownerDocument || parent, parent, node.nodeType === DOCUMENT_FRAGMENT_NODE ? null : node); if (node.nodeType == DOCUMENT_FRAGMENT_NODE) { node.firstChild = node.lastChild = null; } @@ -2082,6 +2116,27 @@ Document.prototype = { */ doctype: null, documentElement: null, + /** + * The number of child elements of the current document. + * + * @type {number} + */ + childElementCount: 0, + + /** + * The first child element of the current document. + * + * @type {Element | null} + */ + firstElementChild: null, + + /** + * The last child element of the current document. + * + * @type {Element | null} + */ + lastElementChild: null, + _inc: 1, insertBefore: function (newChild, refChild) { @@ -2347,6 +2402,44 @@ Element.prototype = { * @type {NamedNodeMap | null} */ attributes: null, + + /** + * The number of child elements of this element. + * + * @type {number} + */ + childElementCount: 0, + + /** + * An element's first child Element, or null if there are no child elements. + * + * @type {Element | null} + */ + firstElementChild: null, + + /** + * An element's last child Element, or null if there are no child elements. + * + * @type {Element | null} + */ + lastElementChild: null, + + /** + * The element immediately following the specified one in its parent's children list, or null + * if the specified element is the last one in the list. + * + * @type {Element | null} + */ + nextElementSibling: null, + + /** + * The element immediately following the specified one in its parent's children list, or null + * if the specified element is the last one in the list. + * + * @type {Element | null} + */ + previousElementSibling: null, + getQualifiedName: function () { return this.prefix ? this.prefix + ':' + this.localName : this.localName; }, @@ -2671,6 +2764,26 @@ function DocumentFragment(symbol) { } DocumentFragment.prototype.nodeName = '#document-fragment'; DocumentFragment.prototype.nodeType = DOCUMENT_FRAGMENT_NODE; +/** + * The amount of child elements the `DocumentFragment` has. + * + * @type {number} + */ +DocumentFragment.prototype.childElementCount = 0; +/** + * The Element that is the first child of the `DocumentFragment` object, or null if there is + * none. + * + * @type {Element | null} + */ +DocumentFragment.prototype.firstElementChild = null; +/** + * The Element that is the last child of the `DocumentFragment` object, or null if there is + * none. + * + * @type {Element | null} + */ +DocumentFragment.prototype.lastElementChild = null; _extends(DocumentFragment, Node); function ProcessingInstruction(symbol) { diff --git a/readme.md b/readme.md index cf96422db..fe25174dc 100644 --- a/readme.md +++ b/readme.md @@ -92,7 +92,7 @@ import { DOMParser } from '@xmldom/xmldom' ```javascript serializeToString(node) ``` -### DOM level2 method and attribute: +### DOM level 2 method and attribute: * [Node](http://www.w3.org/TR/2000/REC-DOM-Level-2-Core-20001113/core.html#ID-1950641247) @@ -289,6 +289,24 @@ import { DOMParser } from '@xmldom/xmldom' - `isDefaultNamespace(namespaceURI)` - `lookupNamespaceURI(prefix)` +### DOM level 4 support: + +* [Element](https://dom.spec.whatwg.org/#interface-element) (via `ParentNode` and `NonDocumentTypeChildNode` mixins) + + attribute: + - `firstElementChild` + - `lastElementChild` + - `childElementCount` + - `previousElementSibling` + - `nextElementSibling` + +* [Document](https://dom.spec.whatwg.org/#interface-document) and [DocumentFragment](https://dom.spec.whatwg.org/#interface-documentfragment) (via `ParentNode` mixin) + + attribute: + - `firstElementChild` + - `lastElementChild` + - `childElementCount` + ### DOM extension by xmldom * [Node] Source position extension; diff --git a/test/dom/dom-implementation.test.js b/test/dom/dom-implementation.test.js index 114694beb..8f55054a5 100644 --- a/test/dom/dom-implementation.test.js +++ b/test/dom/dom-implementation.test.js @@ -62,6 +62,9 @@ describe('DOMImplementation', () => { expect(root.prefix).toBe(null); expect(root.localName).toBe(NAME); expect(doc.documentElement).toBe(root); + expect(doc.childElementCount).toBe(1); + expect(doc.firstElementChild).toBe(root); + expect(doc.lastElementChild).toBe(root); expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION); expect(doc.type).toBe('xml'); }); @@ -80,6 +83,9 @@ describe('DOMImplementation', () => { expect(root.tagName).toBe(NAME); expect(doc.documentElement).toBe(root); + expect(doc.childElementCount).toBe(1); + expect(doc.firstElementChild).toBe(root); + expect(doc.lastElementChild).toBe(root); expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION); expect(doc.type).toBe('xml'); }); @@ -99,25 +105,9 @@ describe('DOMImplementation', () => { expect(root.tagName).toBe(qualifiedName); expect(doc.documentElement).toBe(root); - expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION); - expect(doc.type).toBe('xml'); - }); - - test('should create a Document with root element in a named namespace', () => { - const impl = new DOMImplementation(); - const qualifiedName = `${PREFIX}:${NAME}`; - const doc = impl.createDocument(NS, qualifiedName); - - const root = doc.childNodes.item(0); - expect(root).toBeInstanceOf(Element); - expect(root.ownerDocument).toBe(doc); - expect(root.namespaceURI).toBe(NS); - expect(root.prefix).toBe(PREFIX); - expect(root.localName).toBe(NAME); - expect(root.nodeName).toBe(qualifiedName); - expect(root.tagName).toBe(qualifiedName); - - expect(doc.documentElement).toBe(root); + expect(doc.childElementCount).toBe(1); + expect(doc.firstElementChild).toBe(root); + expect(doc.lastElementChild).toBe(root); expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION); expect(doc.type).toBe('xml'); }); @@ -142,6 +132,9 @@ describe('DOMImplementation', () => { expect(root.tagName).toBe(qualifiedName); expect(doc.documentElement).toBe(root); + expect(doc.childElementCount).toBe(1); + expect(doc.firstElementChild).toBe(root); + expect(doc.lastElementChild).toBe(root); expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION); expect(doc.type).toBe('xml'); }); @@ -210,6 +203,9 @@ describe('DOMImplementation', () => { expect(doc.doctype.ownerDocument).toBe(doc); expect(doc.childNodes.item(0)).toBe(doc.doctype); expect(doc.firstChild).toBe(doc.doctype); + expect(doc.childElementCount).toBe(1); + expect(doc.firstElementChild).toBe(doc.documentElement); + expect(doc.lastElementChild).toBe(doc.documentElement); expect(doc.documentElement).not.toBeNull(); expect(doc.documentElement.localName).toBe('html'); @@ -219,10 +215,14 @@ describe('DOMImplementation', () => { expect(htmlNode.firstChild).not.toBeNull(); expect(htmlNode.firstChild.nodeName).toBe('head'); expect(htmlNode.firstChild.childNodes).toHaveLength(0); + expect(htmlNode.firstElementChild).toBe(htmlNode.firstChild); + expect(htmlNode.firstElementChild.nextElementSibling).toBe(htmlNode.lastChild); expect(htmlNode.lastChild).not.toBeNull(); expect(htmlNode.lastChild.nodeName).toBe('body'); expect(htmlNode.lastChild.childNodes).toHaveLength(0); + expect(htmlNode.lastElementChild).toBe(htmlNode.lastChild); + expect(htmlNode.lastElementChild.previousElementSibling).toBe(htmlNode.firstChild); }); test('should create an HTML document with specified elements including an empty title', () => { const impl = new DOMImplementation(); diff --git a/test/dom/element.test.js b/test/dom/element.test.js index a5eb0401e..1b15262c7 100644 --- a/test/dom/element.test.js +++ b/test/dom/element.test.js @@ -75,8 +75,8 @@ describe('documentElement', () => { expect(doc.documentElement.toString()).toBe('bye'); }); - test('appendElement and removeElement', () => { - const dom = new DOMParser().parseFromString(``, MIME_TYPE.XML_TEXT); + test('appendChild and removeChild', () => { + const dom = new DOMParser().parseFromString(`xy`, MIME_TYPE.XML_TEXT); const doc = dom.documentElement; const arr = []; while (doc.firstChild) { @@ -85,26 +85,45 @@ describe('documentElement', () => { expect(node.parentNode).toBeNull(); expect(node.previousSibling).toBeNull(); expect(node.nextSibling).toBeNull(); + if (node.nodeType == Node.ELEMENT_NODE) { + expect(node.previousElementSibling).toBeNull(); + expect(node.nextElementSibling).toBeNull(); + } expect(node.ownerDocument).toBe(dom); expect(doc.firstChild).not.toBe(node); - const expectedLength = 3 - arr.length; + expect(doc.firstElementChild).not.toBe(node); + expect(doc.lastElementChild).not.toBe(node); + const expectedLength = 5 - arr.length; expect(doc.childNodes).toHaveLength(expectedLength); expect(doc.childNodes.item(expectedLength)).toBeNull(); } - expect(arr).toHaveLength(3); - while (arr.length) { - const node = arr.shift(); + expect(arr).toHaveLength(5); + expect(doc.childElementCount).toBe(0); + expect(doc.firstElementChild).toBeNull(); + expect(doc.lastElementChild).toBeNull(); + for (let i = 0; i < arr.length; i++) { + const node = arr[i]; expect(doc.appendChild(node)).toBe(node); + expect(doc.childNodes).toHaveLength(i + 1); + expect(doc.childNodes.item(i)).toBe(node); expect(node.parentNode).toBe(doc); - const expectedLength = 3 - arr.length; - expect(doc.childNodes).toHaveLength(expectedLength); - expect(doc.childNodes.item(expectedLength - 1)).toBe(node); - if (expectedLength > 1) { - expect(node.previousSibling).toBeInstanceOf(Element); - expect(node.previousSibling.nextSibling).toBe(node); + expect(node.previousSibling).toBe(i == 0 ? null : arr[i - 1]); + expect(node.nextSibling).toBe(null); + if (i > 0) expect(node.previousSibling.nextSibling).toBe(node); + if (node.nodeType == Node.ELEMENT_NODE) { + expect(doc.lastElementChild).toBe(node); + expect(node.previousElementSibling).toBe(i <= 1 ? null : arr[i - 2]); + expect(node.nextElementSibling).toBe(null); + if (i > 1) expect(node.previousElementSibling.nextElementSibling).toBe(node); } } - expect(doc.childNodes.toString()).toBe(``); + expect(doc.childElementCount).toBe(3); + expect(doc.firstElementChild).toBe(arr[0]); + expect(doc.childNodes.toString()).toBe(`xy`); + + doc.removeChild(doc.lastChild); + expect(doc.lastElementChild).toBe(arr[2]); + expect(doc.lastElementChild.nextElementSibling).toBeNull(); }); test('should throw DOMException when trying to append a doctype', () => { diff --git a/test/parse/node.test.js b/test/parse/node.test.js index fa8e46931..b0e192e9e 100644 --- a/test/parse/node.test.js +++ b/test/parse/node.test.js @@ -141,7 +141,12 @@ describe('XML Node Parse', () => { expectNeighbours(first, last); expect(fragment.firstChild).toStrictEqual(first); + expect(fragment.firstElementChild).toStrictEqual(first); + expect(fragment.firstElementChild.nextElementSibling).toStrictEqual(last); expect(fragment.lastChild).toStrictEqual(last); + expect(fragment.lastElementChild).toStrictEqual(last); + expect(fragment.lastElementChild.previousElementSibling).toStrictEqual(first); + expect(fragment.childElementCount).toBe(2); }); }); @@ -163,10 +168,20 @@ describe('XML Node Parse', () => { const first = fragment.appendChild(dom.createElement('first')); const second = fragment.appendChild(dom.createElement('second')); - child.parentNode.insertBefore(fragment, child); + const parent = child.parentNode; + parent.insertBefore(fragment, child); expectNeighbours(first, second, child); - expect(child.parentNode.firstChild).toStrictEqual(first); + expect(parent.firstChild).toStrictEqual(first); + expect(parent.childElementCount).toStrictEqual(3); + expect(parent.firstElementChild).toStrictEqual(first); + expect(parent.lastElementChild).toStrictEqual(child); + expect(first.previousElementSibling).toBeNull(); + expect(first.nextElementSibling).toStrictEqual(second); + expect(second.previousElementSibling).toStrictEqual(first); + expect(second.nextElementSibling).toStrictEqual(child); + expect(child.previousElementSibling).toStrictEqual(second); + expect(child.nextElementSibling).toBeNull(); }); }); From 28aafda50cd3b061fa7e9d0ea08a81b82c1b3272 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Fri, 6 Dec 2024 10:18:18 +0200 Subject: [PATCH 2/2] Add @readonly to the new properties --- lib/dom.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/dom.js b/lib/dom.js index 05d58cacc..d5deb01cb 100644 --- a/lib/dom.js +++ b/lib/dom.js @@ -2120,6 +2120,7 @@ Document.prototype = { * The number of child elements of the current document. * * @type {number} + * @readonly */ childElementCount: 0, @@ -2127,6 +2128,7 @@ Document.prototype = { * The first child element of the current document. * * @type {Element | null} + * @readonly */ firstElementChild: null, @@ -2134,6 +2136,7 @@ Document.prototype = { * The last child element of the current document. * * @type {Element | null} + * @readonly */ lastElementChild: null, @@ -2407,6 +2410,7 @@ Element.prototype = { * The number of child elements of this element. * * @type {number} + * @readonly */ childElementCount: 0, @@ -2414,6 +2418,7 @@ Element.prototype = { * An element's first child Element, or null if there are no child elements. * * @type {Element | null} + * @readonly */ firstElementChild: null, @@ -2421,6 +2426,7 @@ Element.prototype = { * An element's last child Element, or null if there are no child elements. * * @type {Element | null} + * @readonly */ lastElementChild: null, @@ -2429,6 +2435,7 @@ Element.prototype = { * if the specified element is the last one in the list. * * @type {Element | null} + * @readonly */ nextElementSibling: null, @@ -2437,6 +2444,7 @@ Element.prototype = { * if the specified element is the last one in the list. * * @type {Element | null} + * @readonly */ previousElementSibling: null, @@ -2768,6 +2776,7 @@ DocumentFragment.prototype.nodeType = DOCUMENT_FRAGMENT_NODE; * The amount of child elements the `DocumentFragment` has. * * @type {number} + * @readonly */ DocumentFragment.prototype.childElementCount = 0; /** @@ -2775,6 +2784,7 @@ DocumentFragment.prototype.childElementCount = 0; * none. * * @type {Element | null} + * @readonly */ DocumentFragment.prototype.firstElementChild = null; /** @@ -2782,6 +2792,7 @@ DocumentFragment.prototype.firstElementChild = null; * none. * * @type {Element | null} + * @readonly */ DocumentFragment.prototype.lastElementChild = null; _extends(DocumentFragment, Node);