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