Precise DOM morphing
morphing typescript dom

Major refactor

+249 -225
+247 -224
src/morphlex.ts
··· 1 + const ParentNodeTypes = new Set([1, 9, 11]) 2 + 1 3 type IdSet = Set<string> 2 4 type IdMap = WeakMap<Node, IdSet> 3 5 ··· 20 22 afterAttributeUpdated?: (element: Element, attributeName: string, previousValue: string | null) => void 21 23 beforePropertyUpdated?: (node: Node, propertyName: PropertyKey, newValue: unknown) => boolean 22 24 afterPropertyUpdated?: (node: Node, propertyName: PropertyKey, previousValue: unknown) => void 25 + beforeChildrenMorphed?: (parent: ParentNode) => boolean 26 + afterChildrenMorphed?: (parent: ParentNode) => void 23 27 } 24 28 25 - export function morph(node: ChildNode, reference: ChildNode | string, options: Options = {}): void { 26 - if (typeof reference === "string") reference = parseChildNodeFromString(reference) 27 - new Morph(options).morph([node, reference]) 29 + export function morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode> | string, options: Options = {}): void { 30 + if (typeof to === "string") to = parseString(to).childNodes 31 + new Morph(options).morph(from, to) 28 32 } 29 33 30 - export function morphInner(element: Element, reference: Element | string, options: Options = {}): void { 31 - if (typeof reference === "string") reference = parseElementFromString(reference) 32 - new Morph(options).morphInner([element, reference]) 33 - } 34 + export function morphInner(from: ChildNode, to: ChildNode | string, options: Options = {}): void { 35 + if (typeof to === "string") { 36 + const fragment = parseString(to) 34 37 35 - function parseElementFromString(string: string): Element { 36 - const node = parseChildNodeFromString(string) 38 + if (fragment.firstChild && fragment.childNodes.length === 1) { 39 + to = fragment.firstChild 40 + } else { 41 + throw new Error("[Morphlex] The string was not a valid HTML element.") 42 + } 43 + } 37 44 38 - if (isElement(node)) return node 39 - else throw new Error("[Morphlex] The string was not a valid HTML element.") 45 + const pair: PairOfNodes<Node> = [from, to] 46 + if (isElementPair(pair) && isMatchingElementPair(pair)) { 47 + new Morph(options).morphChildren(pair) 48 + } else { 49 + throw new Error("[Morphlex] The nodes are not matching elements.") 50 + } 40 51 } 41 52 42 - function parseChildNodeFromString(string: string): ChildNode { 53 + function parseString(string: string): DocumentFragment { 43 54 const template = document.createElement("template") 44 55 template.innerHTML = string.trim() 45 56 46 - const firstChild = template.content.firstChild 47 - if (firstChild) return firstChild 48 - else throw new Error("[Morphlex] The string was not a valid HTML node.") 57 + return template.content 58 + } 59 + 60 + function moveBefore(parent: ParentNode, node: ChildNode, insertionPoint: ChildNode | null): void { 61 + if (node === insertionPoint) return 62 + 63 + if ("moveBefore" in parent && typeof parent.moveBefore === "function") { 64 + parent.moveBefore(node, insertionPoint) 65 + } else { 66 + parent.insertBefore(node, insertionPoint) 67 + } 49 68 } 50 69 51 - // Feature detection for moveBefore support (cached for performance) 70 + function withAriaBusy(node: Node, block: () => void): void { 71 + if (isElement(node)) { 72 + const originalAriaBusy = node.ariaBusy 73 + node.ariaBusy = "true" 74 + block() 75 + node.ariaBusy = originalAriaBusy 76 + } else block() 77 + } 52 78 53 79 class Morph { 54 - readonly idMap: IdMap 55 - readonly options: Options 80 + private readonly idMap: IdMap = new WeakMap() 81 + private readonly options: Options 56 82 57 83 constructor(options: Options = {}) { 58 - this.idMap = new WeakMap() 59 84 this.options = options 60 85 } 61 86 62 - morph(pair: PairOfNodes<ChildNode>): void { 63 - this.#withAriaBusy(pair[0], () => { 64 - if (isParentNodePair(pair)) this.#buildMaps(pair) 65 - this.#morphNode(pair) 66 - }) 67 - } 87 + morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { 88 + withAriaBusy(from, () => { 89 + if (isParentNode(from)) { 90 + this.mapIdSets(from) 91 + } 68 92 69 - morphInner(pair: PairOfNodes<Element>): void { 70 - this.#withAriaBusy(pair[0], () => { 71 - if (isMatchingElementPair(pair)) { 72 - this.#buildMaps(pair) 73 - this.#morphMatchingElementContent(pair) 93 + if (to instanceof NodeList) { 94 + this.mapIdSetsForEach(to) 95 + } else if (isParentNode(to)) { 96 + this.mapIdSets(to) 97 + } 98 + 99 + if (to instanceof NodeList) { 100 + this.morphOneToMany(from, to) 74 101 } else { 75 - throw new Error("[Morphlex] You can only do an inner morph with matching elements.") 102 + this.morphOneToOne(from, to) 76 103 } 77 104 }) 78 105 } 79 106 80 - #withAriaBusy(node: Node, block: () => void): void { 81 - if (isElement(node)) { 82 - const originalAriaBusy = node.ariaBusy 83 - node.ariaBusy = "true" 84 - block() 85 - node.ariaBusy = originalAriaBusy 86 - } else block() 87 - } 107 + private morphOneToMany(from: ChildNode, to: NodeListOf<ChildNode>): void { 108 + const length = to.length 88 109 89 - #buildMaps([node, reference]: PairOfNodes<ParentNode>): void { 90 - this.#mapIdSets(node) 91 - this.#mapIdSets(reference) 92 - } 93 - 94 - // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 95 - #mapIdSets(node: ParentNode): void { 96 - const elementsWithIds = node.querySelectorAll("[id]") 97 - 98 - const elementsWithIdsLength = elementsWithIds.length 99 - for (let i = 0; i < elementsWithIdsLength; i++) { 100 - const elementWithId = elementsWithIds[i] 101 - const id = elementWithId.id 102 - 103 - // Ignore empty IDs 104 - if (id === "") continue 105 - 106 - let current: Element | null = elementWithId 110 + if (length === 0) { 111 + this.removeNode(from) 112 + } else if (length === 1) { 113 + this.morphOneToOne(from, to[0]!) 114 + } else if (length > 1) { 115 + const newNodes = Array.from(to) 116 + this.morphOneToOne(from, newNodes.shift()!) 117 + const insertionPoint = from.nextSibling 118 + const parent = from.parentNode || document 107 119 108 - while (current) { 109 - const idSet: IdSet | undefined = this.idMap.get(current) 110 - if (idSet) idSet.add(id) 111 - else this.idMap.set(current, new Set([id])) 112 - if (current === node) break 113 - current = current.parentElement 120 + for (const newNode of newNodes) { 121 + if (this.options.beforeNodeAdded?.(newNode) ?? true) { 122 + moveBefore(parent, newNode, insertionPoint) 123 + this.options.afterNodeAdded?.(newNode) 124 + } 114 125 } 115 126 } 116 127 } 117 128 118 - // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. 119 - #morphNode(pair: PairOfNodes<ChildNode>): void { 120 - const [node, reference] = pair 129 + private morphOneToOne(from: ChildNode, to: ChildNode): void { 130 + if (!(this.options.beforeNodeMorphed?.(from, to) ?? true)) return 131 + 132 + const pair: PairOfNodes<ChildNode> = [from, to] 121 133 122 - if (isTextNode(node) && isTextNode(reference)) { 123 - if (node.textContent === reference.textContent) return 134 + if (isElementPair(pair)) { 135 + if (isMatchingElementPair(pair)) { 136 + this.morphMatchingElements(pair) 137 + } else { 138 + this.morphNonMatchingElements(pair) 139 + } 140 + } else { 141 + this.morphOtherNode(pair) 124 142 } 125 143 126 - if (isMatchingElementPair(pair)) this.#morphMatchingElementNode(pair) 127 - else this.#morphOtherNode(pair) 144 + this.options.afterNodeMorphed?.(from, to) 128 145 } 129 146 130 - #morphMatchingElementNode(pair: PairOfMatchingElements<Element>): void { 131 - const [node, reference] = pair 132 - 133 - if (!(this.options.beforeNodeMorphed?.(node, reference) ?? true)) return 134 - 135 - if (node.hasAttributes() || reference.hasAttributes()) this.#morphAttributes(pair) 136 - 137 - // TODO: Should use a branded pair here. 138 - this.#morphMatchingElementContent(pair) 139 - 140 - this.options.afterNodeMorphed?.(node, reference) 141 - } 142 - 143 - #morphOtherNode([node, reference]: PairOfNodes<ChildNode>): void { 144 - if (!(this.options.beforeNodeMorphed?.(node, reference) ?? true)) return 145 - 146 - if (node.nodeType === reference.nodeType && node.nodeValue !== null && reference.nodeValue !== null) { 147 - // Handle text nodes, comments, and CDATA sections. 148 - this.#updateProperty(node, "nodeValue", reference.nodeValue) 149 - } else this.replaceNode(node, reference.cloneNode(true)) 150 - 151 - this.options.afterNodeMorphed?.(node, reference) 147 + private morphMatchingElements(pair: PairOfMatchingElements<Element>): void { 148 + this.morphAttributes(pair) 149 + this.morphProperties(pair) 150 + this.morphChildren(pair) 152 151 } 153 152 154 - #morphMatchingElementContent(pair: PairOfMatchingElements<Element>): void { 155 - const [node, reference] = pair 156 - 157 - if (isHeadElement(node)) { 158 - // We can pass the reference as a head here becuase we know it's the same as the node. 159 - this.#morphHeadContents(pair as PairOfMatchingElements<HTMLHeadElement>) 160 - } else if (node.hasChildNodes() || reference.hasChildNodes()) this.#morphChildNodes(pair) 153 + private morphNonMatchingElements([node, reference]: PairOfNodes<Element>): void { 154 + this.replaceNode(node, reference) 161 155 } 162 156 163 - #morphHeadContents([node, reference]: PairOfMatchingElements<HTMLHeadElement>): void { 164 - const refChildNodesMap: Map<string, Element> = new Map() 165 - 166 - // Generate a map of the reference head element’s child nodes, keyed by their outerHTML. 167 - const referenceChildrenLength = reference.children.length 168 - for (let i = 0; i < referenceChildrenLength; i++) { 169 - const child = reference.children[i] 170 - refChildNodesMap.set(child.outerHTML, child) 157 + private morphOtherNode([node, reference]: PairOfNodes<ChildNode>): void { 158 + // TODO: Improve this logic 159 + // Handle text nodes, comments, and CDATA sections. 160 + if (node.nodeType === reference.nodeType && node.nodeValue !== null && reference.nodeValue !== null) { 161 + this.updateProperty(node, "nodeValue", reference.nodeValue) 162 + } else { 163 + this.replaceNode(node, reference) 171 164 } 172 - 173 - // Iterate backwards to safely remove children without affecting indices 174 - for (let i = node.children.length - 1; i >= 0; i--) { 175 - const child = node.children[i] 176 - const key = child.outerHTML 177 - const refChild = refChildNodesMap.get(key) 178 - 179 - // If the child is in the reference map already, we don't need to add it later. 180 - // If it's not in the map, we need to remove it from the node. 181 - if (refChild) refChildNodesMap.delete(key) 182 - else this.removeNode(child) 183 - } 184 - 185 - // Any remaining nodes in the map should be appended to the head. 186 - for (const refChild of refChildNodesMap.values()) this.appendChild(node, refChild.cloneNode(true)) 187 165 } 188 166 189 - #morphAttributes([element, reference]: PairOfMatchingElements<Element>): void { 167 + private morphAttributes([from, to]: PairOfMatchingElements<Element>): void { 190 168 // Remove any excess attributes from the element that aren’t present in the reference. 191 - for (const { name, value } of element.attributes) { 192 - if (!reference.hasAttribute(name) && (this.options.beforeAttributeUpdated?.(element, name, null) ?? true)) { 193 - element.removeAttribute(name) 194 - this.options.afterAttributeUpdated?.(element, name, value) 169 + for (const { name, value } of from.attributes) { 170 + if (!to.hasAttribute(name) && (this.options.beforeAttributeUpdated?.(from, name, null) ?? true)) { 171 + from.removeAttribute(name) 172 + this.options.afterAttributeUpdated?.(from, name, value) 195 173 } 196 174 } 197 175 198 176 // Copy attributes from the reference to the element, if they don’t already match. 199 - for (const { name, value } of reference.attributes) { 200 - const previousValue = element.getAttribute(name) 201 - if (previousValue !== value && (this.options.beforeAttributeUpdated?.(element, name, value) ?? true)) { 202 - element.setAttribute(name, value) 203 - this.options.afterAttributeUpdated?.(element, name, previousValue) 177 + for (const { name, value } of to.attributes) { 178 + const oldValue = from.getAttribute(name) 179 + if (oldValue !== value && (this.options.beforeAttributeUpdated?.(from, name, value) ?? true)) { 180 + from.setAttribute(name, value) 181 + this.options.afterAttributeUpdated?.(from, name, oldValue) 204 182 } 205 183 } 184 + } 206 185 186 + private morphProperties([element, reference]: PairOfMatchingElements<Element>): void { 207 187 // For certain types of elements, we need to do some extra work to ensure 208 188 // the element’s state matches the reference elements’ state. 209 189 if (isInputElement(element) && isInputElement(reference)) { 210 - this.#updateProperty(element, "checked", reference.checked) 211 - this.#updateProperty(element, "disabled", reference.disabled) 212 - this.#updateProperty(element, "indeterminate", reference.indeterminate) 190 + this.updateProperty(element, "checked", reference.checked) 191 + this.updateProperty(element, "disabled", reference.disabled) 192 + this.updateProperty(element, "indeterminate", reference.indeterminate) 213 193 if ( 214 194 element.type !== "file" && 215 195 !(this.options.ignoreActiveValue && document.activeElement === element) && 216 196 !(this.options.preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue) 217 197 ) { 218 - this.#updateProperty(element, "value", reference.value) 198 + this.updateProperty(element, "value", reference.value) 219 199 } 220 200 } else if (isOptionElement(element) && isOptionElement(reference)) { 221 - this.#updateProperty(element, "selected", reference.selected) 201 + this.updateProperty(element, "selected", reference.selected) 222 202 } else if ( 223 203 isTextAreaElement(element) && 224 204 isTextAreaElement(reference) && 225 205 !(this.options.ignoreActiveValue && document.activeElement === element) && 226 206 !(this.options.preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue) 227 207 ) { 228 - this.#updateProperty(element, "value", reference.value) 208 + this.updateProperty(element, "value", reference.value) 229 209 230 210 const text = element.firstElementChild 231 - if (text) this.#updateProperty(text, "textContent", reference.value) 211 + if (text) this.updateProperty(text, "textContent", reference.value) 232 212 } 233 213 } 234 214 235 - // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 236 - #morphChildNodes(pair: PairOfMatchingElements<Element>): void { 237 - const [element, reference] = pair 215 + morphChildren(pair: PairOfMatchingElements<Element>): void { 216 + const [node, reference] = pair 217 + if (!(this.options.beforeChildrenMorphed?.(node) ?? true)) return 238 218 239 - const childNodes = element.childNodes 240 - const refChildNodes = reference.childNodes 219 + if (isHeadElement(node)) { 220 + this.morphHeadChildren(pair as PairOfMatchingElements<HTMLHeadElement>) 221 + } else if (node.hasChildNodes() || reference.hasChildNodes()) { 222 + this.morphChildNodes(pair) 223 + } 241 224 242 - for (let i = 0; i < refChildNodes.length; i++) { 243 - const child = childNodes[i] as ChildNode | null 244 - const refChild = refChildNodes[i] as ChildNode | null 225 + this.options.afterChildrenMorphed?.(node) 226 + } 245 227 246 - if (child && refChild) { 247 - const pair: PairOfNodes<ChildNode> = [child, refChild] 228 + // TODO: Review this. 229 + private morphHeadChildren([node, reference]: PairOfMatchingElements<HTMLHeadElement>): void { 230 + const refChildNodesMap: Map<string, Element> = new Map() 248 231 249 - if (isMatchingElementPair(pair)) { 250 - if (isHeadElement(pair[0])) { 251 - this.#morphHeadContents(pair as PairOfMatchingElements<HTMLHeadElement>) 252 - } else { 253 - this.#morphChildElement(pair, element) 254 - } 255 - } else this.#morphOtherNode(pair) 256 - } else if (refChild) { 257 - this.appendChild(element, refChild.cloneNode(true)) 258 - } 232 + // Generate a map of the reference head element’s child nodes, keyed by their outerHTML. 233 + const referenceChildrenLength = reference.children.length 234 + for (let i = 0; i < referenceChildrenLength; i++) { 235 + const child = reference.children[i]! 236 + refChildNodesMap.set(child.outerHTML, child) 259 237 } 260 238 261 - // Clean up any excess nodes that may be left over 262 - while (childNodes.length > refChildNodes.length) { 263 - const child = element.lastChild 264 - if (child) this.removeNode(child) 239 + // Iterate backwards to safely remove children without affecting indices 240 + for (let i = node.children.length - 1; i >= 0; i--) { 241 + const child = node.children[i]! 242 + const key = child.outerHTML 243 + const refChild = refChildNodesMap.get(key) 244 + 245 + // If the child is in the reference map already, we don't need to add it later. 246 + // If it's not in the map, we need to remove it from the node. 247 + if (refChild) refChildNodesMap.delete(key) 248 + else this.removeNode(child) 265 249 } 250 + 251 + // Any remaining nodes in the map should be appended to the head. 252 + for (const refChild of refChildNodesMap.values()) this.appendChild(node, refChild) 266 253 } 267 254 268 - #morphChildElement([child, reference]: PairOfMatchingElements<Element>, parent: Element): void { 269 - if (!(this.options.beforeNodeMorphed?.(child, reference) ?? true)) return 255 + private morphChildNodes([from, to]: PairOfMatchingElements<Element>): void { 256 + const fromChildNodes = from.childNodes 257 + const toChildNodes = to.childNodes 258 + 259 + for (let i = 0; i < toChildNodes.length; i++) { 260 + const fromChildNode = fromChildNodes[i] 261 + const toChildNode = toChildNodes[i]! 270 262 271 - const refIdSet = this.idMap.get(reference) 263 + if (fromChildNode && toChildNode) { 264 + if (isElement(toChildNode)) { 265 + this.searchSiblingsToMorphChildElement(fromChildNode, toChildNode, from) 266 + } else { 267 + // TODO 268 + } 269 + } else if (toChildNode) { 270 + this.appendChild(from, toChildNode) 271 + } else if (fromChildNode) { 272 + this.removeNode(fromChildNode) 273 + } 274 + } 275 + } 272 276 273 - // Generate the array in advance of the loop 274 - const refSetArray = refIdSet ? [...refIdSet] : [] 277 + private searchSiblingsToMorphChildElement(from: ChildNode, to: Element, parent: ParentNode): void { 278 + const id = to.id 279 + const idSet = this.idMap.get(to) 280 + const idSetArray = idSet ? [...idSet] : [] 275 281 276 - let currentNode: ChildNode | null = child 277 - let nextMatchByTagName: ChildNode | null = null 282 + let currentNode: ChildNode | null = from 283 + let bestMatch: Element | null = null 284 + let idSetMatches: number = 0 278 285 279 - // Try find a match by idSet, while also looking out for the next best match by tagName. 280 286 while (currentNode) { 281 - if (isElement(currentNode)) { 282 - const id = currentNode.id 287 + if (isElement(currentNode) && currentNode.localName === to.localName) { 288 + // If we found an exact match, this is the best option. 289 + if (id && id !== "" && id === currentNode.id) { 290 + bestMatch = currentNode 291 + break 292 + } 283 293 284 - if (!nextMatchByTagName && currentNode.localName === reference.localName) { 285 - nextMatchByTagName = currentNode 294 + // Try to find the node with the best idSet match 295 + const currentIdSet = this.idMap.get(currentNode) 296 + if (currentIdSet) { 297 + const numberOfMatches = idSetArray.filter((id) => currentIdSet.has(id)).length 298 + if (numberOfMatches > idSetMatches) { 299 + bestMatch = currentNode 300 + idSetMatches = numberOfMatches 301 + } 286 302 } 287 303 288 - if (id !== "") { 289 - if (id === reference.id) { 290 - this.moveBefore(parent, currentNode, child) 291 - return this.#morphNode([currentNode, reference]) 292 - } else { 293 - const currentIdSet = this.idMap.get(currentNode) 294 - 295 - if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 296 - this.moveBefore(parent, currentNode, child) 297 - return this.#morphNode([currentNode, reference]) 298 - } 299 - } 304 + // The fallback is to just use the next element with the same localName 305 + if (!bestMatch) { 306 + bestMatch = currentNode 300 307 } 301 308 } 302 309 303 310 currentNode = currentNode.nextSibling 304 311 } 305 312 306 - // nextMatchByTagName is always set (at minimum to child itself since they have matching tag names) 307 - this.moveBefore(parent, nextMatchByTagName!, child) 308 - this.#morphNode([nextMatchByTagName!, reference]) 309 - 310 - this.options.afterNodeMorphed?.(child, reference) 313 + if (bestMatch) { 314 + if (!(this.options.beforeNodeMorphed?.(bestMatch, to) ?? true)) return 315 + moveBefore(parent, bestMatch, from) 316 + this.options.afterNodeMorphed?.(bestMatch, to) 317 + this.morphMatchingElements([bestMatch, to] as PairOfMatchingElements<Element>) 318 + } else { 319 + this.morphOneToOne(from, to) 320 + } 311 321 } 312 322 313 - #updateProperty<N extends Node, P extends keyof N>(node: N, propertyName: P, newValue: N[P]): void { 314 - const previousValue = node[propertyName] 323 + private updateProperty<N extends Node, P extends keyof N>(node: N, propertyName: P, newValue: N[P]): void { 324 + const oldValue = node[propertyName] 315 325 316 - if (previousValue !== newValue && (this.options.beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) { 326 + if (oldValue !== newValue && (this.options.beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) { 317 327 node[propertyName] = newValue 318 - this.options.afterPropertyUpdated?.(node, propertyName, previousValue) 328 + this.options.afterPropertyUpdated?.(node, propertyName, oldValue) 319 329 } 320 330 } 321 331 322 - private replaceNode(node: ChildNode, newNode: Node): void { 323 - if ((this.options.beforeNodeRemoved?.(node) ?? true) && (this.options.beforeNodeAdded?.(newNode) ?? true)) { 324 - node.replaceWith(newNode) 332 + private replaceNode(node: ChildNode, newNode: ChildNode): void { 333 + if (this.options.beforeNodeAdded?.(newNode) ?? true) { 334 + moveBefore(node.parentNode || document, node, newNode) 325 335 this.options.afterNodeAdded?.(newNode) 326 - this.options.afterNodeRemoved?.(node) 327 336 } 328 - } 329 337 330 - private moveBefore(parent: ParentNode, node: Node, insertionPoint: ChildNode): void { 331 - if (node === insertionPoint) return 332 - 333 - if ("moveBefore" in parent && typeof parent.moveBefore === "function") { 334 - parent.moveBefore(node, insertionPoint) 335 - } else { 336 - parent.insertBefore(node, insertionPoint) 337 - } 338 + this.removeNode(node) 338 339 } 339 340 340 - private appendChild(node: ParentNode, newNode: Node): void { 341 - if (this.options.beforeNodeAdded?.(newNode) ?? true) { 342 - node.appendChild(newNode) 343 - this.options.afterNodeAdded?.(newNode) 341 + private appendChild(parent: ParentNode, newChild: ChildNode): void { 342 + if (this.options.beforeNodeAdded?.(newChild) ?? true) { 343 + moveBefore(parent, newChild, null) 344 + this.options.afterNodeAdded?.(newChild) 344 345 } 345 346 } 346 347 ··· 350 351 this.options.afterNodeRemoved?.(node) 351 352 } 352 353 } 353 - } 354 + 355 + private mapIdSetsForEach(nodeList: NodeList): void { 356 + for (const childNode of nodeList) { 357 + if (isParentNode(childNode)) { 358 + this.mapIdSets(childNode) 359 + } 360 + } 361 + } 354 362 355 - const parentNodeTypes = new Set([1, 9, 11]) 363 + // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 364 + private mapIdSets(node: ParentNode): void { 365 + for (const elementWithId of node.querySelectorAll("[id]")) { 366 + const id = elementWithId.id 356 367 357 - function isMatchingElementPair(pair: PairOfNodes<Node>): pair is PairOfMatchingElements<Element> { 368 + if (id === "") continue 369 + 370 + let currentElement: Element | null = elementWithId 371 + 372 + while (currentElement) { 373 + const idSet: IdSet | undefined = this.idMap.get(currentElement) 374 + if (idSet) idSet.add(id) 375 + else this.idMap.set(currentElement, new Set([id])) 376 + if (currentElement === node) break 377 + currentElement = currentElement.parentElement 378 + } 379 + } 380 + } 381 + } 382 + 383 + function isMatchingElementPair(pair: PairOfNodes<Element>): pair is PairOfMatchingElements<Element> { 358 384 const [a, b] = pair 359 - return isElement(a) && isElement(b) && a.localName === b.localName 385 + return a.localName === b.localName 360 386 } 361 387 362 - function isParentNodePair(pair: PairOfNodes<Node>): pair is PairOfNodes<ParentNode> { 363 - return isParentNode(pair[0]) && isParentNode(pair[1]) 388 + function isElementPair(pair: PairOfNodes<Node>): pair is PairOfNodes<Element> { 389 + const [a, b] = pair 390 + return isElement(a) && isElement(b) 364 391 } 365 392 366 393 function isElement(node: Node): node is Element { ··· 384 411 } 385 412 386 413 function isParentNode(node: Node): node is ParentNode { 387 - return parentNodeTypes.has(node.nodeType) 388 - } 389 - 390 - function isTextNode(node: Node): node is Text { 391 - return node.nodeType === 3 414 + return ParentNodeTypes.has(node.nodeType) 392 415 }
+2 -1
tsconfig.json
··· 14 14 "declaration": true, 15 15 "esModuleInterop": true, 16 16 "allowSyntheticDefaultImports": true, 17 - "sourceMap": true 17 + "sourceMap": true, 18 + "noUncheckedIndexedAccess": true 18 19 }, 19 20 "include": ["src/**/*"], 20 21 "exclude": ["node_modules", "dist", "coverage", "test", "**/*.test.ts", "**/*.spec.ts", "vitest.config.ts"]