import type Document from '../nodes/document/Document.js'; import * as PropertySymbol from '../PropertySymbol.js'; import NamespaceURI from '../config/NamespaceURI.js'; import type HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement.js'; import type Element from '../nodes/element/Element.js'; import type HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement.js'; import type Node from '../nodes/node/Node.js'; import type DocumentFragment from '../nodes/document-fragment/DocumentFragment.js'; import HTMLElementConfig from '../config/HTMLElementConfig.js'; import HTMLElementConfigContentModelEnum from '../config/HTMLElementConfigContentModelEnum.js'; import SVGElementConfig from '../config/SVGElementConfig.js'; import StringUtility from '../utilities/StringUtility.js'; import type BrowserWindow from '../window/BrowserWindow.js'; import type DocumentType from '../nodes/document-type/DocumentType.js'; import type HTMLHeadElement from '../nodes/html-head-element/HTMLHeadElement.js'; import type HTMLBodyElement from '../nodes/html-body-element/HTMLBodyElement.js'; import type HTMLHtmlElement from '../nodes/html-html-element/HTMLHtmlElement.js'; import XMLEncodeUtility from '../utilities/XMLEncodeUtility.js'; import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; import NodeFactory from '../nodes/NodeFactory.js'; import type Text from '../nodes/text/Text.js'; /** * Markup RegExp. * * Group 1: Beginning of start tag (e.g. "div" in ""). * Group 3: Comment start tag "" * Group 5: Document type start tag "" in ""). * Group 8: End of start tag or comment tag (e.g. ">" in "
"). */ const MARKUP_REGEXP = /<([^\s/!>?]+)|<\/([^\s/!>?]+)\s*>|(|--!>)|()|(>)/gm; /** * Attribute RegExp. * * Group 1: Attribute name when the attribute has a value with no apostrophes (e.g. "name" in "
"). * Group 2: Attribute value when the attribute has a value with no apostrophes (e.g. "value" in "
"). * Group 3: Attribute name when the attribute has a value using double apostrophe (e.g. "name" in "
"). * Group 4: Attribute value when the attribute has a value using double apostrophe (e.g. "value" in "
"). * Group 5: Attribute end apostrophe when the attribute has a value using double apostrophe (e.g. '"' in "
"). * Group 6: Attribute name when the attribute has a value using single apostrophe (e.g. "name" in "
"). * Group 7: Attribute value when the attribute has a value using single apostrophe (e.g. "value" in "
"). * Group 8: Attribute end apostrophe when the attribute has a value using single apostrophe (e.g. "'" in "
"). * Group 9: Attribute name when the attribute has no value (e.g. "disabled" in "
"). */ const ATTRIBUTE_REGEXP = /\s*([a-zA-Z0-9-_:.$@?\\<\[\]]+)\s*=\s*([^"'=<>\\`\s]+)|\s*([a-zA-Z0-9-_:.$@?\\<\[\]]+)\s*=\s*"([^"]*)("{0,1})|\s*([a-zA-Z0-9-_:.$@?\\<\[\]]+)\s*=\s*'([^']*)('{0,1})|\s*([a-zA-Z0-9-_:.$@?\\<\[\]]+)/gm; /** * Document type attribute RegExp. * * Group 1: Attribute value. */ const DOCUMENT_TYPE_ATTRIBUTE_REGEXP = /"([^"]+)"/gm; /** * Space RegExp. */ const SPACE_REGEXP = /\s+/; /** * Space in the beginning of string RegExp. */ const SPACE_IN_BEGINNING_REGEXP = /^\s+/; /** * Markup read state (which state the parser is in). */ enum MarkupReadStateEnum { any = 'any', startTag = 'startTag', comment = 'comment', documentType = 'documentType', processingInstruction = 'processingInstruction', rawTextElement = 'rawTextElement' } /** * Document type. */ interface IDocumentType { name: string; publicId: string; systemId: string; } /** * How much of the HTML document that has been parsed (where the parser level is). */ enum HTMLDocumentStructureLevelEnum { root = 0, doctype = 1, documentElement = 2, head = 3, additionalHeadWithoutBody = 4, body = 5, afterBody = 6 } interface IHTMLDocumentStructure { nodes: { doctype: DocumentType | null; documentElement: HTMLHtmlElement; head: HTMLHeadElement; body: HTMLBodyElement; }; level: HTMLDocumentStructureLevelEnum; } /** * HTML parser. */ export default class HTMLParser { private window: BrowserWindow; private evaluateScripts: boolean = false; private isTemplateDocumentFragment: boolean = false; private rootNode: Element | DocumentFragment | Document | null = null; private rootDocument: Document | null = null; private nodeStack: Node[] = []; private tagNameStack: Array = []; private documentStructure: IHTMLDocumentStructure | null = null; private startTagIndex = 0; private markupRegExp: RegExp | null = null; private nextElement: Element | null = null; private currentNode: Node | null = null; private readState: MarkupReadStateEnum = MarkupReadStateEnum.any; /** * Constructor. * * @param window Window. * @param [options] Options. * @param [options.evaluateScripts] Set to "true" to enable script execution * @param [options.isTemplateDocumentFragment] Set to "true" if parsing a template content fragment. */ constructor( window: BrowserWindow, options?: { evaluateScripts?: boolean; isTemplateDocumentFragment?: boolean; } ) { this.window = window; if (options?.evaluateScripts) { this.evaluateScripts = true; } if (options?.isTemplateDocumentFragment) { this.isTemplateDocumentFragment = true; } } /** * Parses HTML a root element containing nodes found. * * @param html HTML string. * @param [rootNode] Root node. * @returns Root node. */ public parse( html: string, rootNode?: Element | DocumentFragment | Document ): Element | DocumentFragment | Document { this.rootNode = rootNode || this.window.document.createDocumentFragment(); this.rootDocument = this.rootNode[PropertySymbol.nodeType] === NodeTypeEnum.documentNode ? this.rootNode : this.window.document; this.nodeStack = [this.rootNode]; this.tagNameStack = [null]; this.currentNode = this.rootNode; this.readState = MarkupReadStateEnum.any; this.documentStructure = null; this.startTagIndex = 0; this.markupRegExp = new RegExp(MARKUP_REGEXP); if (this.rootNode[PropertySymbol.nodeType] === NodeTypeEnum.documentNode) { const { doctype, documentElement, head, body } = this.rootNode; if (!documentElement || !head || !body) { throw new Error( 'Failed to parse HTML: The root node must have "documentElement", "head" and "body".\n\nWe should not end up here and it is therefore a bug in Happy DOM. Please report this issue.' ); } this.documentStructure = { nodes: { doctype: doctype || null, documentElement, head, body }, level: HTMLDocumentStructureLevelEnum.root }; } if (this.rootNode instanceof this.window.HTMLHtmlElement) { const head = this.rootDocument!.createElement('head'); const body = this.rootDocument!.createElement('body'); while (this.rootNode[PropertySymbol.nodeArray].length > 0) { this.rootNode[PropertySymbol.removeChild]( this.rootNode[PropertySymbol.nodeArray][ this.rootNode[PropertySymbol.nodeArray].length - 1 ] ); } this.rootNode[PropertySymbol.appendChild](head); this.rootNode[PropertySymbol.appendChild](body); this.documentStructure = { nodes: { doctype: null, documentElement: this.rootNode, head, body }, level: HTMLDocumentStructureLevelEnum.documentElement }; } let match: RegExpExecArray | null; let lastIndex = 0; html = String(html); while ((match = this.markupRegExp.exec(html))) { switch (this.readState) { case MarkupReadStateEnum.any: // Plain text between tags. if ( match.index !== lastIndex && (match[1] || match[2] || match[3] || match[4] || match[5] !== undefined || match[6]) ) { this.parsePlainText(html.substring(lastIndex, match.index)); } if (match[1]) { // Start tag. this.nextElement = this.getStartTagElement(match[1]); this.startTagIndex = this.markupRegExp.lastIndex; this.readState = MarkupReadStateEnum.startTag; } else if (match[2]) { // End tag. this.parseEndTag(match[2]); } else if (match[3]) { // Comment. this.startTagIndex = this.markupRegExp.lastIndex; this.readState = MarkupReadStateEnum.comment; } else if (match[5] !== undefined) { // Document type. this.startTagIndex = this.markupRegExp.lastIndex; this.readState = MarkupReadStateEnum.documentType; } else if (match[6]) { // Processing instruction. this.startTagIndex = this.markupRegExp.lastIndex; this.readState = MarkupReadStateEnum.processingInstruction; } else { // Plain text between tags, including the matched tag as it is not a valid start or end tag. this.parsePlainText(html.substring(lastIndex, this.markupRegExp.lastIndex)); } break; case MarkupReadStateEnum.startTag: // End of start tag // match[2] is matching an end tag in case the start tag wasn't closed (e.g. "" instead of "
\n"). // match[7] is matching "/>" (e.g. ""). // match[8] is matching ">" (e.g. "
"). if (match[7] || match[8] || match[2]) { if (this.nextElement) { const attributeString = html.substring( this.startTagIndex, match[2] ? this.markupRegExp.lastIndex - 1 : match.index ); const isSelfClosed = !!match[7]; this.parseEndOfStartTag(attributeString, isSelfClosed); } else { // If "nextElement" is set to null, the tag is not allowed (, and are not allowed in an HTML fragment or to be nested). this.readState = MarkupReadStateEnum.any; } } break; case MarkupReadStateEnum.comment: // Comment end tag. if (match[4]) { this.parseComment(html.substring(this.startTagIndex, match.index)); } break; case MarkupReadStateEnum.documentType: // Document type end tag. if (match[7] || match[8]) { this.parseDocumentType(html.substring(this.startTagIndex, match.index)); } break; case MarkupReadStateEnum.processingInstruction: // Processing instruction end tag. if (match[7] || match[8]) { // Processing instructions are not supported in HTML and are rendered as comments. this.parseComment('?' + html.substring(this.startTagIndex, match.index)); } break; case MarkupReadStateEnum.rawTextElement: // End tag of raw text content. //