1// Copyright 2022 Google LLC 2 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6 7// http://www.apache.org/licenses/LICENSE-2.0 8 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15type IElementComment = 16 | { commentNode: undefined; textContent: undefined; hidden: undefined } 17 | { commentNode: Node; textContent: string; hidden: boolean }; 18 19interface ITag { 20 attrs?: Record<string, string | number>; 21 tagName: string; 22} 23 24export interface INewTag extends ITag { 25 content?: string | number; 26 comment?: string; 27} 28 29export type IUpdateTag = Partial<Omit<INewTag, 'tagName'>>; 30 31export default class DOM { 32 static addEntry(containerElement: Element, tagOptions: INewTag) { 33 const doc = containerElement.ownerDocument; 34 const exists = this.alreadyHasEntry(containerElement, tagOptions); 35 36 if (exists) { 37 console.log('Ignored adding entry already available: ', exists.outerHTML); 38 return; 39 } 40 41 let insertPoint: Node | null = containerElement.lastElementChild; //.childNodes[containerElement.childNodes.length - 1]; 42 43 if (!insertPoint) { 44 console.log('Ignored adding entry in empity parent: ', containerElement.outerHTML); 45 return; 46 } 47 48 const { attrs, comment, content, tagName } = tagOptions; 49 50 if (comment) { 51 const commentNode = doc.createComment(comment); 52 this.insertAfterIdented(commentNode, insertPoint); 53 insertPoint = commentNode; 54 } 55 56 const newEl = doc.createElement(tagName); 57 if (content) newEl.innerHTML = content.toString(); 58 if (attrs) 59 Object.entries(attrs).forEach(([attr, value]) => 60 newEl.setAttribute(attr, value.toString()) 61 ); 62 this.insertAfterIdented(newEl, insertPoint); 63 64 return true; 65 } 66 67 static insertBeforeIndented(newNode: Node, referenceNode: Node) { 68 const paddingNode = referenceNode.previousSibling; 69 const ownerDoc = referenceNode.ownerDocument; 70 const containerNode = referenceNode.parentNode; 71 72 if (!paddingNode || !ownerDoc || !containerNode) return; 73 74 const currentPadding = paddingNode.textContent || ''; 75 const textNode = referenceNode.ownerDocument.createTextNode(currentPadding); 76 77 containerNode.insertBefore(newNode, referenceNode); 78 containerNode.insertBefore(textNode, newNode); 79 } 80 81 static insertAfterIdented(newNode: Node, referenceNode: Node) { 82 const paddingNode = referenceNode.previousSibling; 83 const ownerDoc = referenceNode.ownerDocument; 84 const containerNode = referenceNode.parentNode; 85 86 if (!paddingNode || !ownerDoc || !containerNode) return; 87 88 const currentPadding = paddingNode.textContent || ''; 89 const textNode = ownerDoc.createTextNode(currentPadding); 90 91 containerNode.insertBefore(newNode, referenceNode.nextSibling); 92 containerNode.insertBefore(textNode, newNode); 93 } 94 95 static getElementComment(el: Element): IElementComment { 96 const commentNode = el.previousSibling?.previousSibling; 97 98 const out = { commentNode: undefined, textContent: undefined, hidden: undefined }; 99 100 if (!commentNode) return out; 101 102 const textContent = commentNode.textContent || ''; 103 const hidden = textContent.substring(textContent.length - 6) == '@hide '; 104 105 if (!(commentNode && commentNode.nodeName == '#comment')) return out; 106 107 return { commentNode, textContent, hidden: hidden }; 108 } 109 110 static duplicateEntryWithChange( 111 templateElement: Element, 112 options: Omit<IUpdateTag, 'content'> 113 ) { 114 const exists = this.futureEntryAlreadyExist(templateElement, options); 115 if (exists) { 116 console.log('Ignored duplicating entry already available: ', exists.outerHTML); 117 return; 118 } 119 120 const { commentNode } = this.getElementComment(templateElement); 121 let insertPoint: Node = templateElement; 122 123 if (commentNode) { 124 const newComment = commentNode.cloneNode(); 125 this.insertAfterIdented(newComment, insertPoint); 126 insertPoint = newComment; 127 } 128 129 const newEl = templateElement.cloneNode(true) as Element; 130 this.insertAfterIdented(newEl, insertPoint); 131 132 this.updateElement(newEl, options); 133 return true; 134 } 135 136 static replaceStringInAttributeValueOnQueried( 137 root: Element, 138 query: string, 139 attrArray: string[], 140 replaceMap: Map<string, string> 141 ): boolean { 142 let updated = false; 143 const queried = [...Array.from(root.querySelectorAll(query)), root]; 144 145 queried.forEach((el) => { 146 attrArray.forEach((attr) => { 147 if (el.hasAttribute(attr)) { 148 const currentAttrValue = el.getAttribute(attr); 149 150 if (!currentAttrValue) return; 151 152 [...replaceMap.entries()].some(([oldStr, newStr]) => { 153 if ( 154 currentAttrValue.length >= oldStr.length && 155 currentAttrValue.indexOf(oldStr) == 156 currentAttrValue.length - oldStr.length 157 ) { 158 el.setAttribute(attr, currentAttrValue.replace(oldStr, newStr)); 159 updated = true; 160 return true; 161 } 162 return false; 163 }); 164 } 165 }); 166 }); 167 168 return updated; 169 } 170 171 static updateElement(el: Element, updateOptions: IUpdateTag) { 172 const exists = this.futureEntryAlreadyExist(el, updateOptions); 173 if (exists) { 174 console.log('Ignored updating entry already available: ', exists.outerHTML); 175 return; 176 } 177 178 const { comment, attrs, content } = updateOptions; 179 180 if (comment) { 181 const { commentNode } = this.getElementComment(el); 182 if (commentNode) { 183 commentNode.textContent = comment; 184 } 185 } 186 187 if (attrs) { 188 for (const attr in attrs) { 189 const value = attrs[attr]; 190 191 if (value != undefined) { 192 el.setAttribute(attr, `${value}`); 193 } else { 194 el.removeAttribute(attr); 195 } 196 } 197 } 198 199 if (content != undefined) { 200 el.innerHTML = `${content}`; 201 } 202 203 return true; 204 } 205 206 static elementToOptions(el: Element): ITag { 207 return { 208 attrs: this.getAllElementAttributes(el), 209 tagName: el.tagName, 210 }; 211 } 212 213 static getAllElementAttributes(el: Element): Record<string, string> { 214 return el 215 .getAttributeNames() 216 .reduce( 217 (acc, attr) => ({ ...acc, [attr]: el.getAttribute(attr) || '' }), 218 {} as Record<string, string> 219 ); 220 } 221 222 static futureEntryAlreadyExist(el: Element, updateOptions: IUpdateTag) { 223 const currentElOptions = this.elementToOptions(el); 224 225 if (!el.parentElement) { 226 console.log('Checked el has no parent'); 227 process.exit(); 228 } 229 230 return this.alreadyHasEntry(el.parentElement, { 231 ...currentElOptions, 232 ...updateOptions, 233 attrs: { ...currentElOptions.attrs, ...updateOptions.attrs }, 234 }); 235 } 236 237 static alreadyHasEntry( 238 containerElement: Element, 239 { attrs, tagName }: Pick<INewTag, 'attrs' | 'tagName'> 240 ) { 241 const qAttrs = attrs 242 ? Object.entries(attrs) 243 .map(([a, v]) => `[${a}="${v}"]`) 244 .join('') 245 : ''; 246 247 return containerElement.querySelector(tagName + qAttrs); 248 } 249 250 static replaceContentTextOnQueried( 251 root: Element, 252 query: string, 253 replacePairs: Array<[string, string]> 254 ) { 255 let updated = false; 256 let queried = Array.from(root.querySelectorAll(query)); 257 258 if (queried.length == 0) queried = [...Array.from(root.querySelectorAll(query)), root]; 259 260 queried.forEach((el) => { 261 replacePairs.forEach(([oldStr, newStr]) => { 262 if (el.innerHTML == oldStr) { 263 el.innerHTML = newStr; 264 updated = true; 265 } 266 }); 267 }); 268 269 return updated; 270 } 271 272 static XMLDocToString(doc: XMLDocument) { 273 let str = ''; 274 275 doc.childNodes.forEach((node) => { 276 switch (node.nodeType) { 277 case 8: // comment 278 str += `<!--${node.nodeValue}-->\n`; 279 break; 280 281 case 3: // text 282 str += node.textContent; 283 break; 284 285 case 1: // element 286 str += (node as Element).outerHTML; 287 break; 288 289 default: 290 console.log('Unhandled node type: ' + node.nodeType); 291 break; 292 } 293 }); 294 295 return str; 296 } 297} 298