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