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