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