diff --git a/packages/happy-dom/package.json b/packages/happy-dom/package.json index fbfee4e89..26560d5f2 100644 --- a/packages/happy-dom/package.json +++ b/packages/happy-dom/package.json @@ -52,7 +52,7 @@ "webidl-conversions": "^7.0.0", "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0", - "iconv-lite": "^0.6.3" + "iconv-lite": "^0.6.3" }, "devDependencies": { "@types/he": "^1.1.2", diff --git a/packages/happy-dom/src/config/ChildLessElements.ts b/packages/happy-dom/src/config/ChildLessElements.ts deleted file mode 100644 index dc5606eb3..000000000 --- a/packages/happy-dom/src/config/ChildLessElements.ts +++ /dev/null @@ -1 +0,0 @@ -export default ['style', 'script']; diff --git a/packages/happy-dom/src/config/PlainTextElements.ts b/packages/happy-dom/src/config/PlainTextElements.ts index dc5606eb3..e0ac55c37 100644 --- a/packages/happy-dom/src/config/PlainTextElements.ts +++ b/packages/happy-dom/src/config/PlainTextElements.ts @@ -1 +1,4 @@ -export default ['style', 'script']; +export default { + STYLE: true, + SCRIPT: true +}; diff --git a/packages/happy-dom/src/config/UnnestableElements.ts b/packages/happy-dom/src/config/UnnestableElements.ts index 5eb5a3738..1d2820a10 100644 --- a/packages/happy-dom/src/config/UnnestableElements.ts +++ b/packages/happy-dom/src/config/UnnestableElements.ts @@ -1,18 +1,18 @@ -export default [ - 'a', - 'button', - 'dd', - 'dt', - 'form', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'li', - 'option', - 'p', - 'select', - 'table' -]; +export default { + A: true, + BUTTON: true, + DD: true, + DT: true, + FORM: true, + H1: true, + H2: true, + H3: true, + H4: true, + H5: true, + H6: true, + LI: true, + OPTION: true, + P: true, + SELECT: true, + TABLE: true +}; diff --git a/packages/happy-dom/src/config/VoidElements.ts b/packages/happy-dom/src/config/VoidElements.ts index a867539ad..93073f0d1 100644 --- a/packages/happy-dom/src/config/VoidElements.ts +++ b/packages/happy-dom/src/config/VoidElements.ts @@ -1,17 +1,16 @@ -export default [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr', - 'meta' -]; +export default { + AREA: true, + BASE: true, + BR: true, + COL: true, + EMBED: true, + HR: true, + IMG: true, + INPUT: true, + LINK: true, + META: true, + PARAM: true, + SOURCE: true, + TRACK: true, + WBR: true +}; diff --git a/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationElementStyle.ts b/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationElementStyle.ts index 49f1e5226..37091db6d 100644 --- a/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationElementStyle.ts +++ b/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationElementStyle.ts @@ -289,6 +289,10 @@ export default class CSSStyleDeclarationElementStyle { } } else { for (const element of options.elements) { + // Skip @-rules. + if (selectorText.startsWith('@')) { + continue; + } const matchResult = QuerySelector.match(element.element, selectorText); if (matchResult) { element.cssTexts.push({ diff --git a/packages/happy-dom/src/dom-parser/DOMParser.ts b/packages/happy-dom/src/dom-parser/DOMParser.ts index 8656835ca..61cf256a1 100644 --- a/packages/happy-dom/src/dom-parser/DOMParser.ts +++ b/packages/happy-dom/src/dom-parser/DOMParser.ts @@ -44,7 +44,7 @@ export default class DOMParser { newDocument.childNodes.length = 0; newDocument.children.length = 0; - const root = XMLParser.parse(newDocument, string, true); + const root = XMLParser.parse(newDocument, string, { evaluateScripts: true }); let documentElement = null; let documentTypeNode = null; @@ -65,7 +65,7 @@ export default class DOMParser { newDocument.appendChild(documentTypeNode); } newDocument.appendChild(documentElement); - const body = newDocument.querySelector('body'); + const body = newDocument.body; if (body) { for (const child of root.childNodes.slice()) { body.appendChild(child); diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index 9ce6205f6..38a28276f 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -236,9 +236,9 @@ export default class Document extends Node implements IDocument { * @returns Title. */ public get title(): string { - const el = this.querySelector('title'); - if (el) { - return el.textContent; + const element = ParentNodeUtility.getElementByTagName(this, 'title'); + if (element) { + return element.textContent; } return ''; } @@ -248,16 +248,23 @@ export default class Document extends Node implements IDocument { * */ public set title(title: string) { - const el = this.querySelector('title'); - if (el) { - el.textContent = title; + const element = ParentNodeUtility.getElementByTagName(this, 'title'); + if (element) { + element.textContent = title; } else { - const titleEl = this.createElement('title'); - titleEl.textContent = title; - this.head.appendChild(titleEl); + const newElement = this.createElement('title'); + newElement.textContent = title; + this.head.appendChild(newElement); } } + /** + * Returns a collection of all area elements and a elements in a document with a value for the href attribute. + */ + public get links(): IHTMLCollection { + return >this.querySelectorAll('a[href],area[href]'); + } + /** * Last element child. * @@ -430,9 +437,9 @@ export default class Document extends Node implements IDocument { * @returns Base URI. */ public get baseURI(): string { - const base = this.querySelector('base'); - if (base) { - return base.href; + const element = ParentNodeUtility.getElementByTagName(this, 'base'); + if (element) { + return element.href; } return this.defaultView.location.href; } @@ -653,7 +660,7 @@ export default class Document extends Node implements IDocument { * @param html HTML. */ public write(html: string): void { - const root = XMLParser.parse(this, html, true); + const root = XMLParser.parse(this, html, { evaluateScripts: true }); if (this._isFirstWrite || this._isFirstWriteAfterOpen) { if (this._isFirstWrite) { @@ -688,8 +695,8 @@ export default class Document extends Node implements IDocument { this.appendChild(documentElement); } else { - const rootBody = root.querySelector('body'); - const body = this.querySelector('body'); + const rootBody = ParentNodeUtility.getElementByTagName(root, 'body'); + const body = ParentNodeUtility.getElementByTagName(this, 'body'); if (rootBody && body) { for (const child of rootBody.childNodes.slice()) { body.appendChild(child); @@ -697,7 +704,7 @@ export default class Document extends Node implements IDocument { } } - const body = this.querySelector('body'); + const body = ParentNodeUtility.getElementByTagName(this, 'body'); if (body) { for (const child of root.childNodes.slice()) { if (child['tagName'] !== 'HTML' && child.nodeType !== Node.DOCUMENT_TYPE_NODE) { @@ -720,9 +727,10 @@ export default class Document extends Node implements IDocument { this.appendChild(documentElement); } } else { - const bodyNode = root.querySelector('body'); + const bodyNode = ParentNodeUtility.getElementByTagName(root, 'body'); + const body = ParentNodeUtility.getElementByTagName(this, 'body'); for (const child of (bodyNode || root).childNodes.slice()) { - this.body.appendChild(child); + body.appendChild(child); } } } diff --git a/packages/happy-dom/src/nodes/document/IDocument.ts b/packages/happy-dom/src/nodes/document/IDocument.ts index 8dc3475e8..73cf79aa7 100644 --- a/packages/happy-dom/src/nodes/document/IDocument.ts +++ b/packages/happy-dom/src/nodes/document/IDocument.ts @@ -45,6 +45,7 @@ export default interface IDocument extends IParentNode { readonly documentURI: string; readonly visibilityState: VisibilityStateEnum; readonly hidden: boolean; + readonly links: IHTMLCollection; cookie: string; title: string; diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 34c8c0873..9bb6083b4 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -244,9 +244,7 @@ export default class Element extends Node implements IElement { this.removeChild(child); } - for (const node of XMLParser.parse(this.ownerDocument, html).childNodes.slice()) { - this.appendChild(node); - } + XMLParser.parse(this.ownerDocument, html, { rootNode: this }); } /** diff --git a/packages/happy-dom/src/nodes/html-template-element/HTMLTemplateElement.ts b/packages/happy-dom/src/nodes/html-template-element/HTMLTemplateElement.ts index 317ebd0eb..3178c6de5 100644 --- a/packages/happy-dom/src/nodes/html-template-element/HTMLTemplateElement.ts +++ b/packages/happy-dom/src/nodes/html-template-element/HTMLTemplateElement.ts @@ -2,8 +2,8 @@ import HTMLElement from '../html-element/HTMLElement'; import IDocumentFragment from '../document-fragment/IDocumentFragment'; import INode from '../node/INode'; import IHTMLTemplateElement from './IHTMLTemplateElement'; -import XMLParser from '../../xml-parser/XMLParser'; import XMLSerializer from '../../xml-serializer/XMLSerializer'; +import XMLParser from '../../xml-parser/XMLParser'; /** * HTML Template Element. @@ -29,9 +29,7 @@ export default class HTMLTemplateElement extends HTMLElement implements IHTMLTem this.content.removeChild(child); } - for (const node of XMLParser.parse(this.ownerDocument, html).childNodes.slice()) { - this.content.appendChild(node); - } + XMLParser.parse(this.ownerDocument, html, { rootNode: this.content }); } /** diff --git a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts index b3bc54b81..8daedd449 100644 --- a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts +++ b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts @@ -16,16 +16,13 @@ export default class ParentNodeUtility { * @param parentNode Parent node. * @param nodes List of Node or DOMString. */ - public static append(parentNode: INode, ...nodes: (INode | string)[]): void { + public static append( + parentNode: IElement | IDocument | IDocumentFragment, + ...nodes: (INode | string)[] + ): void { for (const node of nodes) { if (typeof node === 'string') { - const newChildNodes = XMLParser.parse( - parentNode.ownerDocument, - node - ).childNodes.slice(); - for (const newChildNode of newChildNodes) { - parentNode.appendChild(newChildNode); - } + XMLParser.parse(parentNode.ownerDocument, node, { rootNode: parentNode }); } else { parentNode.appendChild(node); } @@ -38,9 +35,11 @@ export default class ParentNodeUtility { * @param parentNode Parent node. * @param nodes List of Node or DOMString. */ - public static prepend(parentNode: INode, ...nodes: (string | INode)[]): void { + public static prepend( + parentNode: IElement | IDocument | IDocumentFragment, + ...nodes: (string | INode)[] + ): void { const firstChild = parentNode.firstChild; - for (const node of nodes) { if (typeof node === 'string') { const newChildNodes = XMLParser.parse( @@ -62,7 +61,10 @@ export default class ParentNodeUtility { * @param parentNode Parent node. * @param nodes List of Node or DOMString. */ - public static replaceChildren(parentNode: INode, ...nodes: (string | INode)[]): void { + public static replaceChildren( + parentNode: IElement | IDocument | IDocumentFragment, + ...nodes: (string | INode)[] + ): void { for (const node of parentNode.childNodes.slice()) { parentNode.removeChild(node); } diff --git a/packages/happy-dom/src/nodes/shadow-root/ShadowRoot.ts b/packages/happy-dom/src/nodes/shadow-root/ShadowRoot.ts index 9bbdad41a..3e52c8e14 100644 --- a/packages/happy-dom/src/nodes/shadow-root/ShadowRoot.ts +++ b/packages/happy-dom/src/nodes/shadow-root/ShadowRoot.ts @@ -42,9 +42,7 @@ export default class ShadowRoot extends DocumentFragment implements IShadowRoot this.removeChild(child); } - for (const node of XMLParser.parse(this.ownerDocument, html).childNodes.slice()) { - this.appendChild(node); - } + XMLParser.parse(this.ownerDocument, html, { rootNode: this }); } /** diff --git a/packages/happy-dom/src/query-selector/ISelectorPseudo.ts b/packages/happy-dom/src/query-selector/ISelectorPseudo.ts index 462ff7ce2..f712c745c 100644 --- a/packages/happy-dom/src/query-selector/ISelectorPseudo.ts +++ b/packages/happy-dom/src/query-selector/ISelectorPseudo.ts @@ -1,4 +1,8 @@ +import SelectorItem from './SelectorItem'; + export default interface ISelectorPseudo { name: string; arguments: string | null; + selectorItem: SelectorItem | null; + nthFunction: ((n: number) => boolean) | null; } diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts index 64b777e97..59223b056 100644 --- a/packages/happy-dom/src/query-selector/QuerySelector.ts +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts @@ -9,6 +9,11 @@ import IDocumentFragment from '../nodes/document-fragment/IDocumentFragment'; import SelectorParser from './SelectorParser'; import ISelectorMatch from './ISelectorMatch'; +type IDocumentPositionAndElement = { + documentPosition: string; + element: IElement; +}; + /** * Utility for query selection in an HTML element. * @@ -26,9 +31,6 @@ export default class QuerySelector { node: IElement | IDocument | IDocumentFragment, selector: string ): INodeList { - const nodeList = new NodeList(); - const allMatches = {}; - if (selector === '') { throw new Error( "Failed to execute 'querySelectorAll' on 'Element': The provided selector is empty." @@ -36,19 +38,30 @@ export default class QuerySelector { } if (selector === null || selector === undefined) { - return nodeList; + return new NodeList(); } - for (const items of SelectorParser.getSelectorGroups(selector)) { - const matches = + const groups = SelectorParser.getSelectorGroups(selector); + let matches: IDocumentPositionAndElement[] = []; + + for (const items of groups) { + matches = matches.concat( node.nodeType === NodeTypeEnum.elementNode ? this.findAll(node, [node], items) - : this.findAll(null, node.children, items); - Object.assign(allMatches, matches); + : this.findAll(null, node.children, items) + ); + } + + const nodeList = new NodeList(); + const matchesMap: { [position: string]: IElement } = {}; + + for (let i = 0, max = matches.length; i < max; i++) { + matchesMap[matches[i].documentPosition] = matches[i].element; } - for (const key of Object.keys(allMatches).sort()) { - nodeList.push(allMatches[key]); + const keys = Object.keys(matchesMap).sort(); + for (let i = 0, max = keys.length; i < max; i++) { + nodeList.push(matchesMap[keys[i]]); } return nodeList; @@ -201,10 +214,10 @@ export default class QuerySelector { children: IElement[], selectorItems: SelectorItem[], documentPosition?: string - ): { [documentPosition: string]: IElement } { + ): IDocumentPositionAndElement[] { const selectorItem = selectorItems[0]; const nextSelectorItem = selectorItems[1]; - const matched: { [documentPosition: string]: IElement } = {}; + let matched: IDocumentPositionAndElement[] = []; for (let i = 0, max = children.length; i < max; i++) { const child = children[i]; @@ -213,14 +226,16 @@ export default class QuerySelector { if (selectorItem.match(child)) { if (!nextSelectorItem) { if (rootElement !== child) { - matched[position] = child; + matched.push({ + documentPosition: position, + element: child + }); } } else { switch (nextSelectorItem.combinator) { case SelectorCombinatorEnum.adjacentSibling: if (child.nextElementSibling) { - Object.assign( - matched, + matched = matched.concat( this.findAll( rootElement, [child.nextElementSibling], @@ -232,8 +247,7 @@ export default class QuerySelector { break; case SelectorCombinatorEnum.descendant: case SelectorCombinatorEnum.child: - Object.assign( - matched, + matched = matched.concat( this.findAll(rootElement, child.children, selectorItems.slice(1), position) ); break; @@ -242,7 +256,9 @@ export default class QuerySelector { } if (selectorItem.combinator === SelectorCombinatorEnum.descendant && child.children.length) { - Object.assign(matched, this.findAll(rootElement, child.children, selectorItems, position)); + matched = matched.concat( + this.findAll(rootElement, child.children, selectorItems, position) + ); } } diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts index 673e4c57b..361295d31 100644 --- a/packages/happy-dom/src/query-selector/SelectorItem.ts +++ b/packages/happy-dom/src/query-selector/SelectorItem.ts @@ -4,7 +4,6 @@ import Element from '../nodes/element/Element'; import IHTMLInputElement from '../nodes/html-input-element/IHTMLInputElement'; import SelectorCombinatorEnum from './SelectorCombinatorEnum'; import ISelectorAttribute from './ISelectorAttribute'; -import SelectorParser from './SelectorParser'; import ISelectorMatch from './ISelectorMatch'; import ISelectorPseudo from './ISelectorPseudo'; @@ -181,74 +180,40 @@ export default class SelectorItem { case 'root': return element.tagName === 'HTML'; case 'not': - return !SelectorParser.getSelectorItem(psuedo.arguments).match(element); + return !psuedo.selectorItem.match(element); case 'nth-child': - return this.matchNthChild(element, parent.children, psuedo.arguments); + const nthChildIndex = psuedo.selectorItem + ? parent.children.filter((child) => psuedo.selectorItem.match(child)).indexOf(element) + : parent.children.indexOf(element); + return nthChildIndex !== -1 && psuedo.nthFunction(nthChildIndex + 1); case 'nth-of-type': if (!element.parentNode) { return false; } - return this.matchNthChild( - element, - parent.children.filter((child) => child.tagName === element.tagName), - psuedo.arguments - ); + const nthOfTypeIndex = parent.children + .filter((child) => child.tagName === element.tagName) + .indexOf(element); + return nthOfTypeIndex !== -1 && psuedo.nthFunction(nthOfTypeIndex + 1); case 'nth-last-child': - return this.matchNthChild(element, parent.children.reverse(), psuedo.arguments); + const nthLastChildIndex = psuedo.selectorItem + ? parent.children + .filter((child) => psuedo.selectorItem.match(child)) + .reverse() + .indexOf(element) + : parent.children.reverse().indexOf(element); + return nthLastChildIndex !== -1 && psuedo.nthFunction(nthLastChildIndex + 1); case 'nth-last-of-type': - return this.matchNthChild( - element, - parent.children.filter((child) => child.tagName === element.tagName).reverse(), - psuedo.arguments - ); + const nthLastOfTypeIndex = parent.children + .filter((child) => child.tagName === element.tagName) + .reverse() + .indexOf(element); + return nthLastOfTypeIndex !== -1 && psuedo.nthFunction(nthLastOfTypeIndex + 1); } } return true; } - /** - * Matches a nth-child selector. - * - * @param element Element. - * @param parentChildren Parent children. - * @param placement Placement. - * @returns True if it is a match. - */ - private matchNthChild(element: IElement, parentChildren: IElement[], placement: string): boolean { - if (placement === 'odd') { - const index = parentChildren.indexOf(element); - return index !== -1 && (index + 1) % 2 !== 0; - } else if (placement === 'even') { - const index = parentChildren.indexOf(element); - return index !== -1 && (index + 1) % 2 === 0; - } else if (placement.includes('n')) { - const [a, b] = placement.replace(/ /g, '').split('n'); - const childIndex = parentChildren.indexOf(element); - const aNumber = a !== '' ? Number(a) : 1; - const bNumber = b !== undefined ? Number(b) : 0; - if (isNaN(aNumber) || isNaN(bNumber)) { - throw new DOMException(`The selector "${this.getSelectorString()}" is not valid.`); - } - - for (let i = 0, max = parentChildren.length; i <= max; i += aNumber) { - if (childIndex === i + bNumber - 1) { - return true; - } - } - - return false; - } - - const number = Number(placement); - - if (isNaN(number)) { - throw new DOMException(`The selector "${this.getSelectorString()}" is not valid.`); - } - - return parentChildren[number - 1] === element; - } - /** * Matches attribute. * diff --git a/packages/happy-dom/src/query-selector/SelectorParser.ts b/packages/happy-dom/src/query-selector/SelectorParser.ts index ca1843fdf..56c29e256 100644 --- a/packages/happy-dom/src/query-selector/SelectorParser.ts +++ b/packages/happy-dom/src/query-selector/SelectorParser.ts @@ -1,6 +1,7 @@ import SelectorItem from './SelectorItem'; import SelectorCombinatorEnum from './SelectorCombinatorEnum'; import DOMException from '../exception/DOMException'; +import ISelectorPseudo from './ISelectorPseudo'; /** * Selector RegExp. @@ -29,6 +30,20 @@ const SELECTOR_REGEXP = */ const CLASS_ESCAPED_CHARACTER_REGEXP = /\\/g; +/** + * Nth Function. + */ +const NTH_FUNCTION = { + odd: (n: number) => (n + 1) % 2 === 0, + even: (n: number) => (n + 1) % 2 !== 0, + alwaysFalse: () => false +}; + +/** + * Space RegExp. + */ +const SPACE_REGEXP = / /g; + /** * Simple Selector RegExp. * @@ -120,16 +135,10 @@ export default class SelectorParser { }); } else if (match[12] && match[13]) { currentSelectorItem.pseudos = currentSelectorItem.pseudos || []; - currentSelectorItem.pseudos.push({ - name: match[12].toLowerCase(), - arguments: match[13] - }); + currentSelectorItem.pseudos.push(this.getPseudo(match[12], match[13])); } else if (match[14]) { currentSelectorItem.pseudos = currentSelectorItem.pseudos || []; - currentSelectorItem.pseudos.push({ - name: match[14].toLowerCase(), - arguments: null - }); + currentSelectorItem.pseudos.push(this.getPseudo(match[14])); } else if (match[15]) { switch (match[15].trim()) { case ',': @@ -168,4 +177,104 @@ export default class SelectorParser { return groups; } + + /** + * Returns pseudo. + * + * @param name Pseudo name. + * @param args Pseudo arguments. + * @returns Pseudo. + */ + private static getPseudo(name: string, args?: string): ISelectorPseudo { + const lowerName = name.toLowerCase(); + + if (!args) { + return { name: lowerName, arguments: null, selectorItem: null, nthFunction: null }; + } + + switch (lowerName) { + case 'nth-last-child': + case 'nth-child': + const nthOfIndex = args.indexOf(' of '); + const nthFunction = nthOfIndex !== -1 ? args.substring(0, nthOfIndex) : args; + const selectorItem = + nthOfIndex !== -1 ? this.getSelectorItem(args.substring(nthOfIndex + 4).trim()) : null; + return { + name: lowerName, + arguments: args, + selectorItem, + nthFunction: this.getPseudoNthFunction(nthFunction) + }; + case 'nth-of-type': + case 'nth-last-of-type': + return { + name: lowerName, + arguments: args, + selectorItem: null, + nthFunction: this.getPseudoNthFunction(args) + }; + case 'not': + return { + name: lowerName, + arguments: args, + selectorItem: this.getSelectorItem(args), + nthFunction: null + }; + default: + return { name: lowerName, arguments: args, selectorItem: null, nthFunction: null }; + } + } + + /** + * Returns pseudo nth function. + * + * Based on: + * https://github.com/dperini/nwsapi/blob/master/src/nwsapi.js + * + * @param args Pseudo arguments. + * @returns Pseudo nth function. + */ + private static getPseudoNthFunction(args?: string): ((n: number) => boolean) | null { + if (args === 'odd') { + return NTH_FUNCTION.odd; + } else if (args === 'even') { + return NTH_FUNCTION.even; + } + + const parts = args.replace(SPACE_REGEXP, '').split('n'); + let partA = parseInt(parts[0], 10) || 0; + + if (parts[0] == '-') { + partA = -1; + } + + if (parts.length === 1) { + return (n) => n == partA; + } + + let partB = parseInt(parts[1], 10) || 0; + + if (parts[0] == '+') { + partB = 1; + } + + if (partA >= 1 || partA <= -1) { + if (partA >= 1) { + if (Math.abs(partA) === 1) { + return (n: number): boolean => n > partB - 1; + } + return (n: number): boolean => n > partB - 1 && (n + -1 * partB) % partA === 0; + } + if (Math.abs(partA) === 1) { + return (n: number): boolean => n < partB + 1; + } + return (n) => n < partB + 1 && (n + -1 * partB) % partA === 0; + } + + if (parts[0]) { + return (n) => n === partB; + } + + return (n) => n > partB - 1; + } } diff --git a/packages/happy-dom/src/range/Range.ts b/packages/happy-dom/src/range/Range.ts index a0eed4897..927bb774d 100644 --- a/packages/happy-dom/src/range/Range.ts +++ b/packages/happy-dom/src/range/Range.ts @@ -406,7 +406,7 @@ export default class Range { */ public createContextualFragment(tagString: string): IDocumentFragment { // TODO: We only have support for HTML in the parser currently, so it is not necessary to check which context it is - return XMLParser.parse(this._ownerDocument, tagString); + return XMLParser.parse(this._ownerDocument, tagString); } /** diff --git a/packages/happy-dom/src/xml-parser/XMLParser.ts b/packages/happy-dom/src/xml-parser/XMLParser.ts index 0b7db6b08..0d8e61ef7 100755 --- a/packages/happy-dom/src/xml-parser/XMLParser.ts +++ b/packages/happy-dom/src/xml-parser/XMLParser.ts @@ -1,25 +1,59 @@ -import Node from '../nodes/node/Node'; -import Element from '../nodes/element/Element'; import IDocument from '../nodes/document/IDocument'; import VoidElements from '../config/VoidElements'; import UnnestableElements from '../config/UnnestableElements'; -import ChildLessElements from '../config/ChildLessElements'; -import { decode } from 'he'; import NamespaceURI from '../config/NamespaceURI'; import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement'; -import INode from '../nodes/node/INode'; import IElement from '../nodes/element/IElement'; import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement'; -import IDocumentFragment from '../nodes/document-fragment/IDocumentFragment'; import PlainTextElements from '../config/PlainTextElements'; +import IDocumentType from '../nodes/document-type/IDocumentType'; +import INode from '../nodes/node/INode'; +import IDocumentFragment from '../nodes/document-fragment/IDocumentFragment'; +import { decode } from 'he'; + +/** + * Markup RegExp. + * + * Group 1: Beginning of start tag (e.g. "div" in ""). + * Group 3: Comment with ending "--" (e.g. " Comment 1 " in ""). + * Group 4: Comment without ending "--" (e.g. " Comment 1 " in "||<([!?])([^>]*)>/gi; +/** + * Document type attribute RegExp. + * + * Group 1: Attribute value. + */ const DOCUMENT_TYPE_ATTRIBUTE_REGEXP = /"([^"]+)"/gm; -const ATTRIBUTE_REGEXP = /([^\s=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))/gms; /** * XML parser. @@ -29,121 +63,233 @@ export default class XMLParser { * Parses XML/HTML and returns a root element. * * @param document Document. - * @param data HTML data. - * @param [evaluateScripts = false] Set to "true" to enable script execution. - * @returns Root element. + * @param xml XML/HTML string. + * @param [options] Options. + * @param [options.rootNode] Node to append elements to. Otherwise a new DocumentFragment is created. + * @param [options.evaluateScripts = false] Set to "true" to enable script execution. + * @returns Root node. */ public static parse( document: IDocument, - data: string, - evaluateScripts = false - ): IDocumentFragment { - const root = document.createDocumentFragment(); - const stack: Array = [root]; - const markupRegexp = new RegExp(MARKUP_REGEXP, 'gi'); - let parent: IDocumentFragment | IElement = root; - let parentTagName = null; - let parentUnnestableTagName = null; - let lastTextIndex = 0; + xml: string, + options?: { rootNode?: IElement | IDocumentFragment | IDocument; evaluateScripts?: boolean } + ): IElement | IDocumentFragment | IDocument { + const root = options && options.rootNode ? options.rootNode : document.createDocumentFragment(); + const stack: INode[] = [root]; + const markupRegexp = new RegExp(MARKUP_REGEXP, 'gm'); + const { evaluateScripts = false } = options || {}; + const unnestableTagNames: string[] = []; + let currentNode: INode | null = root; let match: RegExpExecArray; + let plainTextTagName: string | null = null; + let readState: MarkupReadStateEnum = MarkupReadStateEnum.startOrEndTag; + let startTagIndex = 0; + let lastIndex = 0; - if (data !== null && data !== undefined) { - data = String(data); - - while ((match = markupRegexp.exec(data))) { - const tagName = match[2].toLowerCase(); - const isStartTag = !match[1]; - - if (parent && match.index !== lastTextIndex) { - const text = data.substring(lastTextIndex, match.index); - if (parentTagName && PlainTextElements.includes(parentTagName)) { - parent.appendChild(document.createTextNode(text)); - } else { - let condCommMatch; - let condCommEndMatch; - const condCommRegexp = new RegExp(CONDITION_COMMENT_REGEXP, 'gi'); - const condCommEndRegexp = new RegExp(CONDITION_COMMENT_END_REGEXP, 'gi'); - // @Refer: https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/?redirectedfrom=MSDN + if (xml !== null && xml !== undefined) { + xml = String(xml); + + while ((match = markupRegexp.exec(xml))) { + switch (readState) { + case MarkupReadStateEnum.startOrEndTag: if ( - isStartTag && - (condCommMatch = condCommRegexp.exec(text)) && - condCommMatch[0] && - (condCommEndMatch = condCommEndRegexp.exec(data.substring(markupRegexp.lastIndex))) && - condCommEndMatch[0] + match.index !== lastIndex && + (match[1] || match[2] || match[3] || match[4] || match[5] || match[6]) + ) { + // Plain text between tags. + + currentNode.appendChild( + document.createTextNode(xml.substring(lastIndex, match.index)) + ); + } + + if (match[1]) { + // Start tag. + + const tagName = match[1].toUpperCase(); + + // Some elements are not allowed to be nested (e.g. "" is not allowed.). + // Therefore we need to auto-close the tag, so that it become valid (e.g. ""). + const unnestableTagNameIndex = unnestableTagNames.indexOf(tagName); + if (unnestableTagNameIndex !== -1) { + unnestableTagNames.splice(unnestableTagNameIndex, 1); + while (currentNode !== root) { + if ((currentNode).tagName === tagName) { + stack.pop(); + currentNode = stack[stack.length - 1] || root; + break; + } + stack.pop(); + currentNode = stack[stack.length - 1] || root; + } + } + + // NamespaceURI is inherited from the parent element. + // It should default to SVG for SVG elements. + const namespaceURI = + tagName === 'SVG' + ? NamespaceURI.svg + : (currentNode).namespaceURI || NamespaceURI.html; + const newElement = document.createElementNS(namespaceURI, tagName); + + currentNode.appendChild(newElement); + currentNode = newElement; + stack.push(currentNode); + readState = MarkupReadStateEnum.insideStartTag; + startTagIndex = markupRegexp.lastIndex; + } else if (match[2]) { + // End tag. + + if (match[2].toUpperCase() === (currentNode).tagName) { + // Some elements are not allowed to be nested (e.g. "" is not allowed.). + // Therefore we need to auto-close the tag, so that it become valid (e.g. ""). + const unnestableTagNameIndex = unnestableTagNames.indexOf( + (currentNode).tagName + ); + if (unnestableTagNameIndex !== -1) { + unnestableTagNames.splice(unnestableTagNameIndex, 1); + } + + stack.pop(); + currentNode = stack[stack.length - 1] || root; + } + } else if ( + match[3] || + match[4] || + (match[6] && (currentNode).namespaceURI === NamespaceURI.html) ) { - markupRegexp.lastIndex += condCommEndRegexp.lastIndex; - continue; + // Comment. + + currentNode.appendChild( + document.createComment((match[6] ? '?' : '') + (match[3] || match[4] || match[6])) + ); + } else if (match[5]) { + // Exclamation mark comment (usually ). + + currentNode.appendChild( + this.getDocumentTypeNode(document, match[5]) || document.createComment(match[5]) + ); + } else if (match[6]) { + // Processing instruction (not supported by HTML). + // TODO: Add support for processing instructions. } else { - this.appendTextAndCommentNodes(document, parent, text); + // Plain text between tags, including the match as it is not a valid start or end tag. + + currentNode.appendChild( + document.createTextNode(xml.substring(lastIndex, markupRegexp.lastIndex)) + ); } - } - } - if (isStartTag) { - const namespaceURI = - tagName === 'svg' - ? NamespaceURI.svg - : (parent).namespaceURI || NamespaceURI.html; - const newElement = document.createElementNS(namespaceURI, tagName); - - // Scripts are not allowed to be executed when they are parsed using innerHTML, outerHTML, replaceWith() etc. - // However, they are allowed to be executed when document.write() is used. - // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement - if (tagName === 'script') { - (newElement)._evaluateScript = evaluateScripts; - } - - // An assumption that the same rule should be applied for the HTMLLinkElement is made here. - if (tagName === 'link') { - (newElement)._evaluateCSS = evaluateScripts; - } - - this.setAttributes(newElement, match[3]); - - if (!match[4] && !VoidElements.includes(tagName)) { - // Some elements are not allowed to be nested (e.g. "" is not allowed.). - // Therefore we will auto-close the tag. - if (parentUnnestableTagName === tagName) { - stack.pop(); - parent = parent.parentNode || root; + break; + case MarkupReadStateEnum.insideStartTag: + // End of start tag + if (match[7] || match[8]) { + // End of start tag. + + // Attribute name and value. + + const attributeString = xml.substring(startTagIndex, match.index); + let hasAttributeStringEnded = true; + + if (!!attributeString) { + const attributeRegexp = new RegExp(ATTRIBUTE_REGEXP, 'gm'); + let attributeMatch: RegExpExecArray; + + while ((attributeMatch = attributeRegexp.exec(attributeString))) { + if ( + (attributeMatch[1] && attributeMatch[2] === attributeMatch[4]) || + (attributeMatch[5] && attributeMatch[6] === attributeMatch[8]) || + attributeMatch[9] + ) { + // Valid attribute name and value. + + const name = attributeMatch[1] || attributeMatch[5] || attributeMatch[9] || ''; + const rawValue = attributeMatch[3] || attributeMatch[7] || ''; + const value = rawValue ? decode(rawValue) : ''; + const namespaceURI = + (currentNode).tagName === 'SVG' && name === 'xmlns' ? value : null; + + (currentNode).setAttributeNS(namespaceURI, name, value); + + startTagIndex += attributeMatch[0].length; + } else if (!attributeMatch[4] && !attributeMatch[8]) { + // End attribute apostrophe is missing (e.g. "attr='value" or 'attr="value'). + + hasAttributeStringEnded = false; + break; + } + } + } + + // We need to check if the attribute string is read completely. + // The attribute string can potentially contain "/>" or ">". + if (hasAttributeStringEnded) { + // Checks if the tag is a self closing tag (ends with "/>") or void element. + // When it is a self closing tag or void element it should be closed immediately. + // Self closing tags are not allowed in the HTML namespace, but the parser should still allow it for void elements. + // Self closing tags is supported in the SVG namespace. + if ( + VoidElements[(currentNode).tagName] || + (match[7] && (currentNode).namespaceURI === NamespaceURI.svg) + ) { + stack.pop(); + currentNode = stack[stack.length - 1] || root; + readState = MarkupReadStateEnum.startOrEndTag; + } else { + // Plain text elements such as @@ -202,24 +249,24 @@ describe('XMLParser', () => { expect((root.children[0].children[1]).innerText).toBe(''); expect((root.children[0].children[2]).innerText).toBe(''); - expect(new XMLSerializer().serializeToString(root).replace(/[\s]/gm, '')).toBe( + expect(new XMLSerializer().serializeToString(root)).toBe( `
-
`.replace(/[\s]/gm, '') + ` ); const root2 = XMLParser.parse( - window.document, + document, ` - - Title - - - - -` + + Title + + + + + ` ); expect((root2.children[0].children[1].children[0]).innerText).toBe( 'var vars = []; for (var i=0;i { }); it('Handles unclosed regular elements.', () => { - const root = XMLParser.parse(window.document, `
test`); + const root = XMLParser.parse(document, `
test`); expect(root.childNodes.length).toBe(1); expect((root.childNodes[0]).tagName).toBe('DIV'); @@ -236,7 +283,7 @@ describe('XMLParser', () => { it('Parses an SVG with "xmlns" set to HTML.', () => { const root = XMLParser.parse( - window.document, + document, `
@@ -318,7 +365,7 @@ describe('XMLParser', () => { expect(svg.attributes['xmlns'].ownerElement === svg).toBe(true); expect(svg.attributes['xmlns'].ownerDocument === document).toBe(true); - expect(new XMLSerializer().serializeToString(root).replace(/[\s]/gm, '')).toBe( + expect(new XMLSerializer().serializeToString(root)).toBe( `
@@ -330,13 +377,13 @@ describe('XMLParser', () => {
- `.replace(/[\s]/gm, '') + ` ); }); it('Parses a malformed SVG with "xmlns" set to HTML.', () => { const root = XMLParser.parse( - window.document, + document, `
@@ -358,10 +405,10 @@ describe('XMLParser', () => { ` ); - expect(new XMLSerializer().serializeToString(root).replace(/[\s]/gm, '')).toBe( + expect(new XMLSerializer().serializeToString(root).replace(/\s/gm, '')).toBe( `
- + @@ -375,15 +422,15 @@ describe('XMLParser', () => { - -
- `.replace(/[\s]/gm, '') + + +
`.replace(/\s/gm, '') ); }); it('Parses childless elements with start and end tag names in different case', () => { const root = XMLParser.parse( - window.document, + document, ` ` @@ -393,16 +440,16 @@ describe('XMLParser', () => { }); it('Handles different value types.', () => { - const root1 = XMLParser.parse(window.document, null); + const root1 = XMLParser.parse(document, null); expect(new XMLSerializer().serializeToString(root1)).toBe(''); - const root2 = XMLParser.parse(window.document, undefined); + const root2 = XMLParser.parse(document, undefined); expect(new XMLSerializer().serializeToString(root2)).toBe(''); - const root3 = XMLParser.parse(window.document, (1000)); + const root3 = XMLParser.parse(document, (1000)); expect(new XMLSerializer().serializeToString(root3)).toBe('1000'); - const root4 = XMLParser.parse(window.document, (false)); + const root4 = XMLParser.parse(document, (false)); expect(new XMLSerializer().serializeToString(root4)).toBe('false'); }); @@ -454,16 +501,13 @@ describe('XMLParser', () => { ]; for (const html of testHTML) { - const root = XMLParser.parse(window.document, html); + const root = XMLParser.parse(document, html); expect(new XMLSerializer().serializeToString(root)).toBe(html); } }); it('Parses