• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1import { TAG_ID as $, NS, NUMBERED_HEADERS } from '../common/html.js';
2import type { TreeAdapter, TreeAdapterTypeMap } from '../tree-adapters/interface.js';
3
4//Element utils
5const IMPLICIT_END_TAG_REQUIRED = new Set([$.DD, $.DT, $.LI, $.OPTGROUP, $.OPTION, $.P, $.RB, $.RP, $.RT, $.RTC]);
6const IMPLICIT_END_TAG_REQUIRED_THOROUGHLY = new Set([
7    ...IMPLICIT_END_TAG_REQUIRED,
8    $.CAPTION,
9    $.COLGROUP,
10    $.TBODY,
11    $.TD,
12    $.TFOOT,
13    $.TH,
14    $.THEAD,
15    $.TR,
16]);
17const SCOPING_ELEMENTS_HTML = new Set([
18    $.APPLET,
19    $.CAPTION,
20    $.HTML,
21    $.MARQUEE,
22    $.OBJECT,
23    $.TABLE,
24    $.TD,
25    $.TEMPLATE,
26    $.TH,
27]);
28const SCOPING_ELEMENTS_HTML_LIST = new Set([...SCOPING_ELEMENTS_HTML, $.OL, $.UL]);
29const SCOPING_ELEMENTS_HTML_BUTTON = new Set([...SCOPING_ELEMENTS_HTML, $.BUTTON]);
30const SCOPING_ELEMENTS_MATHML = new Set([$.ANNOTATION_XML, $.MI, $.MN, $.MO, $.MS, $.MTEXT]);
31const SCOPING_ELEMENTS_SVG = new Set([$.DESC, $.FOREIGN_OBJECT, $.TITLE]);
32
33const TABLE_ROW_CONTEXT = new Set([$.TR, $.TEMPLATE, $.HTML]);
34const TABLE_BODY_CONTEXT = new Set([$.TBODY, $.TFOOT, $.THEAD, $.TEMPLATE, $.HTML]);
35const TABLE_CONTEXT = new Set([$.TABLE, $.TEMPLATE, $.HTML]);
36const TABLE_CELLS = new Set([$.TD, $.TH]);
37
38export interface StackHandler<T extends TreeAdapterTypeMap> {
39    onItemPush: (node: T['parentNode'], tid: number, isTop: boolean) => void;
40    onItemPop: (node: T['parentNode'], isTop: boolean) => void;
41}
42
43//Stack of open elements
44export class OpenElementStack<T extends TreeAdapterTypeMap> {
45    items: T['parentNode'][] = [];
46    tagIDs: $[] = [];
47    current: T['parentNode'];
48    stackTop = -1;
49    tmplCount = 0;
50
51    currentTagId = $.UNKNOWN;
52
53    get currentTmplContentOrNode(): T['parentNode'] {
54        return this._isInTemplate() ? this.treeAdapter.getTemplateContent(this.current) : this.current;
55    }
56
57    constructor(
58        document: T['document'],
59        private treeAdapter: TreeAdapter<T>,
60        private handler: StackHandler<T>,
61    ) {
62        this.current = document;
63    }
64
65    //Index of element
66    private _indexOf(element: T['element']): number {
67        return this.items.lastIndexOf(element, this.stackTop);
68    }
69
70    //Update current element
71    private _isInTemplate(): boolean {
72        return this.currentTagId === $.TEMPLATE && this.treeAdapter.getNamespaceURI(this.current) === NS.HTML;
73    }
74
75    private _updateCurrentElement(): void {
76        this.current = this.items[this.stackTop];
77        this.currentTagId = this.tagIDs[this.stackTop];
78    }
79
80    //Mutations
81    push(element: T['element'], tagID: $): void {
82        this.stackTop++;
83
84        this.items[this.stackTop] = element;
85        this.current = element;
86        this.tagIDs[this.stackTop] = tagID;
87        this.currentTagId = tagID;
88
89        if (this._isInTemplate()) {
90            this.tmplCount++;
91        }
92
93        this.handler.onItemPush(element, tagID, true);
94    }
95
96    pop(): void {
97        const popped = this.current;
98        if (this.tmplCount > 0 && this._isInTemplate()) {
99            this.tmplCount--;
100        }
101
102        this.stackTop--;
103        this._updateCurrentElement();
104
105        this.handler.onItemPop(popped, true);
106    }
107
108    replace(oldElement: T['element'], newElement: T['element']): void {
109        const idx = this._indexOf(oldElement);
110
111        this.items[idx] = newElement;
112
113        if (idx === this.stackTop) {
114            this.current = newElement;
115        }
116    }
117
118    insertAfter(referenceElement: T['element'], newElement: T['element'], newElementID: $): void {
119        const insertionIdx = this._indexOf(referenceElement) + 1;
120
121        this.items.splice(insertionIdx, 0, newElement);
122        this.tagIDs.splice(insertionIdx, 0, newElementID);
123        this.stackTop++;
124
125        if (insertionIdx === this.stackTop) {
126            this._updateCurrentElement();
127        }
128
129        this.handler.onItemPush(this.current, this.currentTagId, insertionIdx === this.stackTop);
130    }
131
132    popUntilTagNamePopped(tagName: $): void {
133        let targetIdx = this.stackTop + 1;
134
135        do {
136            targetIdx = this.tagIDs.lastIndexOf(tagName, targetIdx - 1);
137        } while (targetIdx > 0 && this.treeAdapter.getNamespaceURI(this.items[targetIdx]) !== NS.HTML);
138
139        this.shortenToLength(targetIdx < 0 ? 0 : targetIdx);
140    }
141
142    shortenToLength(idx: number): void {
143        while (this.stackTop >= idx) {
144            const popped = this.current;
145
146            if (this.tmplCount > 0 && this._isInTemplate()) {
147                this.tmplCount -= 1;
148            }
149
150            this.stackTop--;
151            this._updateCurrentElement();
152
153            this.handler.onItemPop(popped, this.stackTop < idx);
154        }
155    }
156
157    popUntilElementPopped(element: T['element']): void {
158        const idx = this._indexOf(element);
159        this.shortenToLength(idx < 0 ? 0 : idx);
160    }
161
162    private popUntilPopped(tagNames: Set<$>, targetNS: NS): void {
163        const idx = this._indexOfTagNames(tagNames, targetNS);
164        this.shortenToLength(idx < 0 ? 0 : idx);
165    }
166
167    popUntilNumberedHeaderPopped(): void {
168        this.popUntilPopped(NUMBERED_HEADERS, NS.HTML);
169    }
170
171    popUntilTableCellPopped(): void {
172        this.popUntilPopped(TABLE_CELLS, NS.HTML);
173    }
174
175    popAllUpToHtmlElement(): void {
176        //NOTE: here we assume that the root <html> element is always first in the open element stack, so
177        //we perform this fast stack clean up.
178        this.tmplCount = 0;
179        this.shortenToLength(1);
180    }
181
182    private _indexOfTagNames(tagNames: Set<$>, namespace: NS): number {
183        for (let i = this.stackTop; i >= 0; i--) {
184            if (tagNames.has(this.tagIDs[i]) && this.treeAdapter.getNamespaceURI(this.items[i]) === namespace) {
185                return i;
186            }
187        }
188        return -1;
189    }
190
191    private clearBackTo(tagNames: Set<$>, targetNS: NS): void {
192        const idx = this._indexOfTagNames(tagNames, targetNS);
193        this.shortenToLength(idx + 1);
194    }
195
196    clearBackToTableContext(): void {
197        this.clearBackTo(TABLE_CONTEXT, NS.HTML);
198    }
199
200    clearBackToTableBodyContext(): void {
201        this.clearBackTo(TABLE_BODY_CONTEXT, NS.HTML);
202    }
203
204    clearBackToTableRowContext(): void {
205        this.clearBackTo(TABLE_ROW_CONTEXT, NS.HTML);
206    }
207
208    remove(element: T['element']): void {
209        const idx = this._indexOf(element);
210
211        if (idx >= 0) {
212            if (idx === this.stackTop) {
213                this.pop();
214            } else {
215                this.items.splice(idx, 1);
216                this.tagIDs.splice(idx, 1);
217                this.stackTop--;
218                this._updateCurrentElement();
219                this.handler.onItemPop(element, false);
220            }
221        }
222    }
223
224    //Search
225    tryPeekProperlyNestedBodyElement(): T['element'] | null {
226        //Properly nested <body> element (should be second element in stack).
227        return this.stackTop >= 1 && this.tagIDs[1] === $.BODY ? this.items[1] : null;
228    }
229
230    contains(element: T['element']): boolean {
231        return this._indexOf(element) > -1;
232    }
233
234    getCommonAncestor(element: T['element']): T['element'] | null {
235        const elementIdx = this._indexOf(element) - 1;
236
237        return elementIdx >= 0 ? this.items[elementIdx] : null;
238    }
239
240    isRootHtmlElementCurrent(): boolean {
241        return this.stackTop === 0 && this.tagIDs[0] === $.HTML;
242    }
243
244    //Element in scope
245    private hasInDynamicScope(tagName: $, htmlScope: Set<$>): boolean {
246        for (let i = this.stackTop; i >= 0; i--) {
247            const tn = this.tagIDs[i];
248
249            switch (this.treeAdapter.getNamespaceURI(this.items[i])) {
250                case NS.HTML: {
251                    if (tn === tagName) return true;
252                    if (htmlScope.has(tn)) return false;
253                    break;
254                }
255                case NS.SVG: {
256                    if (SCOPING_ELEMENTS_SVG.has(tn)) return false;
257                    break;
258                }
259                case NS.MATHML: {
260                    if (SCOPING_ELEMENTS_MATHML.has(tn)) return false;
261                    break;
262                }
263            }
264        }
265
266        return true;
267    }
268
269    hasInScope(tagName: $): boolean {
270        return this.hasInDynamicScope(tagName, SCOPING_ELEMENTS_HTML);
271    }
272
273    hasInListItemScope(tagName: $): boolean {
274        return this.hasInDynamicScope(tagName, SCOPING_ELEMENTS_HTML_LIST);
275    }
276
277    hasInButtonScope(tagName: $): boolean {
278        return this.hasInDynamicScope(tagName, SCOPING_ELEMENTS_HTML_BUTTON);
279    }
280
281    hasNumberedHeaderInScope(): boolean {
282        for (let i = this.stackTop; i >= 0; i--) {
283            const tn = this.tagIDs[i];
284
285            switch (this.treeAdapter.getNamespaceURI(this.items[i])) {
286                case NS.HTML: {
287                    if (NUMBERED_HEADERS.has(tn)) return true;
288                    if (SCOPING_ELEMENTS_HTML.has(tn)) return false;
289                    break;
290                }
291                case NS.SVG: {
292                    if (SCOPING_ELEMENTS_SVG.has(tn)) return false;
293                    break;
294                }
295                case NS.MATHML: {
296                    if (SCOPING_ELEMENTS_MATHML.has(tn)) return false;
297                    break;
298                }
299            }
300        }
301
302        return true;
303    }
304
305    hasInTableScope(tagName: $): boolean {
306        for (let i = this.stackTop; i >= 0; i--) {
307            if (this.treeAdapter.getNamespaceURI(this.items[i]) !== NS.HTML) {
308                continue;
309            }
310
311            switch (this.tagIDs[i]) {
312                case tagName: {
313                    return true;
314                }
315                case $.TABLE:
316                case $.HTML: {
317                    return false;
318                }
319            }
320        }
321
322        return true;
323    }
324
325    hasTableBodyContextInTableScope(): boolean {
326        for (let i = this.stackTop; i >= 0; i--) {
327            if (this.treeAdapter.getNamespaceURI(this.items[i]) !== NS.HTML) {
328                continue;
329            }
330
331            switch (this.tagIDs[i]) {
332                case $.TBODY:
333                case $.THEAD:
334                case $.TFOOT: {
335                    return true;
336                }
337                case $.TABLE:
338                case $.HTML: {
339                    return false;
340                }
341            }
342        }
343
344        return true;
345    }
346
347    hasInSelectScope(tagName: $): boolean {
348        for (let i = this.stackTop; i >= 0; i--) {
349            if (this.treeAdapter.getNamespaceURI(this.items[i]) !== NS.HTML) {
350                continue;
351            }
352
353            switch (this.tagIDs[i]) {
354                case tagName: {
355                    return true;
356                }
357                case $.OPTION:
358                case $.OPTGROUP: {
359                    break;
360                }
361                default: {
362                    return false;
363                }
364            }
365        }
366
367        return true;
368    }
369
370    //Implied end tags
371    generateImpliedEndTags(): void {
372        while (IMPLICIT_END_TAG_REQUIRED.has(this.currentTagId)) {
373            this.pop();
374        }
375    }
376
377    generateImpliedEndTagsThoroughly(): void {
378        while (IMPLICIT_END_TAG_REQUIRED_THOROUGHLY.has(this.currentTagId)) {
379            this.pop();
380        }
381    }
382
383    generateImpliedEndTagsWithExclusion(exclusionId: $): void {
384        while (this.currentTagId !== exclusionId && IMPLICIT_END_TAG_REQUIRED_THOROUGHLY.has(this.currentTagId)) {
385            this.pop();
386        }
387    }
388}
389