Precise DOM morphing
morphing typescript dom

Make JS output prettier

+119 -135
+99 -132
dist/morphlex.js
··· 1 1 export function morph(node, guide) { 2 - const idMap = new Map(); 3 - if (isParentNode(node) && isParentNode(guide)) { 4 - populateIdSets(node, idMap); 5 - populateIdSets(guide, idMap); 6 - } 7 - morphNodes(node, guide, idMap); 2 + const idMap = new Map(); 3 + if (isParentNode(node) && isParentNode(guide)) { 4 + populateIdSets(node, idMap); 5 + populateIdSets(guide, idMap); 6 + } 7 + morphNodes(node, guide, idMap); 8 8 } 9 9 // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 10 10 function populateIdSets(node, idMap) { 11 - const elementsWithIds = node.querySelectorAll("[id]"); 12 - for (const elementWithId of elementsWithIds) { 13 - const id = elementWithId.id; 14 - // Ignore empty IDs 15 - if (id === "") 16 - continue; 17 - let current = elementWithId; 18 - while (current) { 19 - const idSet = idMap.get(current); 20 - idSet ? idSet.add(id) : idMap.set(current, new Set([id])); 21 - if (current === elementWithId) 22 - break; 23 - current = current.parentElement; 24 - } 25 - } 11 + const elementsWithIds = node.querySelectorAll("[id]"); 12 + for (const elementWithId of elementsWithIds) { 13 + const id = elementWithId.id; 14 + // Ignore empty IDs 15 + if (id === "") continue; 16 + let current = elementWithId; 17 + while (current) { 18 + const idSet = idMap.get(current); 19 + idSet ? idSet.add(id) : idMap.set(current, new Set([id])); 20 + if (current === elementWithId) break; 21 + current = current.parentElement; 22 + } 23 + } 26 24 } 27 25 // This is where we actually morph the nodes. The `morph` function exists to set up the `idMap`. 28 26 function morphNodes(node, guide, idMap, insertBefore, parent) { 29 - // TODO: We should extract this into a separate function. 30 - if (parent && insertBefore && insertBefore !== node) 31 - parent.insertBefore(guide, insertBefore); 32 - if (isElement(node) && isElement(guide) && node.tagName === guide.tagName) { 33 - // We need to check if the element is an input, option, or textarea here, because they have 34 - // special attributes not covered by the isEqualNode check. 35 - if (!isInput(node) && !isOption(node) && !isTextArea(node) && node.isEqualNode(guide)) 36 - return; 37 - else { 38 - if (node.hasAttributes() || guide.hasAttributes()) 39 - morphAttributes(node, guide); 40 - if (node.hasChildNodes() || guide.hasChildNodes()) 41 - morphChildNodes(node, guide, idMap); 42 - } 43 - } 44 - else { 45 - if (node.isEqualNode(guide)) 46 - return; 47 - else if (isText(node) && isText(guide)) { 48 - if (node.textContent !== guide.textContent) 49 - node.textContent = guide.textContent; 50 - } 51 - else if (isComment(node) && isComment(guide)) { 52 - if (node.nodeValue !== guide.nodeValue) 53 - node.nodeValue = guide.nodeValue; 54 - } 55 - else 56 - node.replaceWith(guide.cloneNode(true)); 57 - } 27 + // TODO: We should extract this into a separate function. 28 + if (parent && insertBefore && insertBefore !== node) parent.insertBefore(guide, insertBefore); 29 + if (isElement(node) && isElement(guide) && node.tagName === guide.tagName) { 30 + // We need to check if the element is an input, option, or textarea here, because they have 31 + // special attributes not covered by the isEqualNode check. 32 + if (!isInput(node) && !isOption(node) && !isTextArea(node) && node.isEqualNode(guide)) return; 33 + else { 34 + if (node.hasAttributes() || guide.hasAttributes()) morphAttributes(node, guide); 35 + if (node.hasChildNodes() || guide.hasChildNodes()) morphChildNodes(node, guide, idMap); 36 + } 37 + } else { 38 + if (node.isEqualNode(guide)) return; 39 + else if (isText(node) && isText(guide)) { 40 + if (node.textContent !== guide.textContent) node.textContent = guide.textContent; 41 + } else if (isComment(node) && isComment(guide)) { 42 + if (node.nodeValue !== guide.nodeValue) node.nodeValue = guide.nodeValue; 43 + } else node.replaceWith(guide.cloneNode(true)); 44 + } 58 45 } 59 46 function morphAttributes(elem, guide) { 60 - // Remove any excess attributes from the element that aren’t present in the guide. 61 - for (const { name } of elem.attributes) 62 - guide.hasAttribute(name) || elem.removeAttribute(name); 63 - // Copy attributes from the guide to the element, if they don’t already match. 64 - for (const { name, value } of guide.attributes) 65 - elem.getAttribute(name) === value || elem.setAttribute(name, value); 66 - elem.nodeValue; 67 - // For certain types of elements, we need to do some extra work to ensure the element’s state matches the guide’s state. 68 - if (isInput(elem) && isInput(guide)) { 69 - if (elem.checked !== guide.checked) 70 - elem.checked = guide.checked; 71 - if (elem.disabled !== guide.disabled) 72 - elem.disabled = guide.disabled; 73 - if (elem.indeterminate !== guide.indeterminate) 74 - elem.indeterminate = guide.indeterminate; 75 - if (elem.type !== "file" && elem.value !== guide.value) 76 - elem.value = guide.value; 77 - } 78 - else if (isOption(elem) && isOption(guide) && elem.value !== guide.value) 79 - elem.value = guide.value; 80 - else if (isTextArea(elem) && isTextArea(guide)) { 81 - if (elem.value !== guide.value) 82 - elem.value = guide.value; 83 - const text = elem.firstChild; 84 - if (text && isText(text) && text.textContent !== guide.value) 85 - text.textContent = guide.value; 86 - } 47 + // Remove any excess attributes from the element that aren’t present in the guide. 48 + for (const { name } of elem.attributes) guide.hasAttribute(name) || elem.removeAttribute(name); 49 + // Copy attributes from the guide to the element, if they don’t already match. 50 + for (const { name, value } of guide.attributes) elem.getAttribute(name) === value || elem.setAttribute(name, value); 51 + elem.nodeValue; 52 + // For certain types of elements, we need to do some extra work to ensure the element’s state matches the guide’s state. 53 + if (isInput(elem) && isInput(guide)) { 54 + if (elem.checked !== guide.checked) elem.checked = guide.checked; 55 + if (elem.disabled !== guide.disabled) elem.disabled = guide.disabled; 56 + if (elem.indeterminate !== guide.indeterminate) elem.indeterminate = guide.indeterminate; 57 + if (elem.type !== "file" && elem.value !== guide.value) elem.value = guide.value; 58 + } else if (isOption(elem) && isOption(guide) && elem.value !== guide.value) elem.value = guide.value; 59 + else if (isTextArea(elem) && isTextArea(guide)) { 60 + if (elem.value !== guide.value) elem.value = guide.value; 61 + const text = elem.firstChild; 62 + if (text && isText(text) && text.textContent !== guide.value) text.textContent = guide.value; 63 + } 87 64 } 88 65 // Iterates over the child nodes of the guide element, morphing the main element’s child nodes to match. 89 66 function morphChildNodes(elem, guide, idMap) { 90 - const childNodes = [...elem.childNodes]; 91 - const guideChildNodes = [...guide.childNodes]; 92 - for (let i = 0; i < guideChildNodes.length; i++) { 93 - const child = childNodes.at(i); 94 - const guideChild = guideChildNodes.at(i); 95 - if (child && guideChild) 96 - morphChildNode(child, guideChild, elem, idMap); 97 - else if (guideChild) 98 - elem.appendChild(guideChild.cloneNode(true)); 99 - else if (child) 100 - child.remove(); 101 - } 102 - // Remove any excess child nodes from the main element. This is separate because 103 - // the loop above might modify the length of the main element’s child nodes. 104 - while (elem.childNodes.length > guide.childNodes.length) 105 - elem.lastChild?.remove(); 67 + const childNodes = [...elem.childNodes]; 68 + const guideChildNodes = [...guide.childNodes]; 69 + for (let i = 0; i < guideChildNodes.length; i++) { 70 + const child = childNodes.at(i); 71 + const guideChild = guideChildNodes.at(i); 72 + if (child && guideChild) morphChildNode(child, guideChild, elem, idMap); 73 + else if (guideChild) elem.appendChild(guideChild.cloneNode(true)); 74 + else if (child) child.remove(); 75 + } 76 + // Remove any excess child nodes from the main element. This is separate because 77 + // the loop above might modify the length of the main element’s child nodes. 78 + while (elem.childNodes.length > guide.childNodes.length) elem.lastChild?.remove(); 106 79 } 107 80 function morphChildNode(child, guide, parent, idMap) { 108 - if (isElement(child) && isElement(guide)) 109 - morphChildElement(child, guide, parent, idMap); 110 - else 111 - morphNodes(child, guide, idMap); 81 + if (isElement(child) && isElement(guide)) morphChildElement(child, guide, parent, idMap); 82 + else morphNodes(child, guide, idMap); 112 83 } 113 84 function morphChildElement(child, guide, parent, idMap) { 114 - const guideIdSet = idMap.get(guide); 115 - // Generate the array in advance of the loop 116 - const guideSetArray = guideIdSet ? [...guideIdSet] : []; 117 - let currentNode = child; 118 - let nextMatchByTagName = null; 119 - // Try find a match by idSet, while also looking out for the next best match by tagName. 120 - while (currentNode) { 121 - if (isElement(currentNode)) { 122 - if (currentNode.id === guide.id) { 123 - return morphNodes(currentNode, guide, idMap, child, parent); 124 - } 125 - else if (currentNode.id !== "") { 126 - const currentIdSet = idMap.get(currentNode); 127 - if (currentIdSet && guideSetArray.some((it) => currentIdSet.has(it))) { 128 - return morphNodes(currentNode, guide, idMap, child, parent); 129 - } 130 - } 131 - else if (!nextMatchByTagName && currentNode.tagName === guide.tagName) { 132 - nextMatchByTagName = currentNode; 133 - } 134 - } 135 - currentNode = currentNode.nextSibling; 136 - } 137 - if (nextMatchByTagName) 138 - morphNodes(nextMatchByTagName, guide, idMap, child, parent); 139 - else 140 - child.replaceWith(guide.cloneNode(true)); 85 + const guideIdSet = idMap.get(guide); 86 + // Generate the array in advance of the loop 87 + const guideSetArray = guideIdSet ? [...guideIdSet] : []; 88 + let currentNode = child; 89 + let nextMatchByTagName = null; 90 + // Try find a match by idSet, while also looking out for the next best match by tagName. 91 + while (currentNode) { 92 + if (isElement(currentNode)) { 93 + if (currentNode.id === guide.id) { 94 + return morphNodes(currentNode, guide, idMap, child, parent); 95 + } else if (currentNode.id !== "") { 96 + const currentIdSet = idMap.get(currentNode); 97 + if (currentIdSet && guideSetArray.some((it) => currentIdSet.has(it))) { 98 + return morphNodes(currentNode, guide, idMap, child, parent); 99 + } 100 + } else if (!nextMatchByTagName && currentNode.tagName === guide.tagName) { 101 + nextMatchByTagName = currentNode; 102 + } 103 + } 104 + currentNode = currentNode.nextSibling; 105 + } 106 + if (nextMatchByTagName) morphNodes(nextMatchByTagName, guide, idMap, child, parent); 107 + else child.replaceWith(guide.cloneNode(true)); 141 108 } 142 109 // We cannot use `instanceof` when nodes might be from different documents, 143 110 // so we use type guards instead. This keeps TypeScript happy, while doing 144 111 // the necessary checks at runtime. 145 112 function isText(node) { 146 - return node.nodeType === 3; 113 + return node.nodeType === 3; 147 114 } 148 115 function isComment(node) { 149 - return node.nodeType === 8; 116 + return node.nodeType === 8; 150 117 } 151 118 function isElement(node) { 152 - return node.nodeType === 1; 119 + return node.nodeType === 1; 153 120 } 154 121 function isInput(element) { 155 - return element.localName === "input"; 122 + return element.localName === "input"; 156 123 } 157 124 function isOption(element) { 158 - return element.localName === "option"; 125 + return element.localName === "option"; 159 126 } 160 127 function isTextArea(element) { 161 - return element.localName === "textarea"; 128 + return element.localName === "textarea"; 162 129 } 163 130 function isParentNode(node) { 164 - return node.nodeType === 1 || node.nodeType === 9 || node.nodeType === 11; 131 + return node.nodeType === 1 || node.nodeType === 9 || node.nodeType === 11; 165 132 } 166 - //# sourceMappingURL=morphlex.js.map 133 + //# sourceMappingURL=morphlex.js.map
+18 -2
package-lock.json
··· 1 1 { 2 2 "name": "morphlex", 3 - "version": "0.0.1", 3 + "version": "0.0.3", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "morphlex", 9 - "version": "0.0.1", 9 + "version": "0.0.3", 10 10 "license": "MIT", 11 11 "devDependencies": { 12 12 "@open-wc/testing": "^3.0.0-next.5", 13 13 "@web/test-runner": "^0.18.0", 14 14 "eslint": "^8.57.0", 15 + "prettier": "^3.2.5", 15 16 "terser": "^5.28.1", 16 17 "typescript": "^5.3.3", 17 18 "typescript-eslint": "^7.0.2" ··· 4507 4508 "dev": true, 4508 4509 "engines": { 4509 4510 "node": ">= 0.8.0" 4511 + } 4512 + }, 4513 + "node_modules/prettier": { 4514 + "version": "3.2.5", 4515 + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", 4516 + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", 4517 + "dev": true, 4518 + "bin": { 4519 + "prettier": "bin/prettier.cjs" 4520 + }, 4521 + "engines": { 4522 + "node": ">=14" 4523 + }, 4524 + "funding": { 4525 + "url": "https://github.com/prettier/prettier?sponsor=1" 4510 4526 } 4511 4527 }, 4512 4528 "node_modules/progress": {
+2 -1
package.json
··· 7 7 "types": "dist/morphlex.d.ts", 8 8 "scripts": { 9 9 "test": "web-test-runner test/**/*.test.js --node-resolve", 10 - "build": "tsc", 10 + "build": "tsc && prettier --write ./src ./dist", 11 11 "watch": "tsc -w", 12 12 "test:watch": "npm run test -- --watch", 13 13 "lint": "eslint ./src", ··· 21 21 "@open-wc/testing": "^3.0.0-next.5", 22 22 "@web/test-runner": "^0.18.0", 23 23 "eslint": "^8.57.0", 24 + "prettier": "^3.2.5", 24 25 "terser": "^5.28.1", 25 26 "typescript": "^5.3.3", 26 27 "typescript-eslint": "^7.0.2"