diff --git a/packages/happy-dom/src/PropertySymbol.ts b/packages/happy-dom/src/PropertySymbol.ts index be9bee7b5..c19a2afef 100644 --- a/packages/happy-dom/src/PropertySymbol.ts +++ b/packages/happy-dom/src/PropertySymbol.ts @@ -82,7 +82,6 @@ export const scrollWidth = Symbol('scrollWidth'); export const scrollTop = Symbol('scrollTop'); export const scrollLeft = Symbol('scrollLeft'); export const attributes = Symbol('attributes'); -export const attributesProxy = Symbol('attributesProxy'); export const namespaceURI = Symbol('namespaceURI'); export const accessKey = Symbol('accessKey'); export const accessKeyLabel = Symbol('accessKeyLabel'); @@ -376,6 +375,7 @@ export const rotate = Symbol('rotate'); export const bindMethods = Symbol('bindMethods'); export const xmlProcessingInstruction = Symbol('xmlProcessingInstruction'); export const root = Symbol('root'); -export const filterNode = Symbol('filterNode'); export const customElementReactionStack = Symbol('customElementReactionStack'); +export const isRemoved = Symbol('isRemoved'); +export const currentNode = Symbol('currentNode'); export const dispatching = Symbol('dispatching'); diff --git a/packages/happy-dom/src/config/HTMLElementConfig.ts b/packages/happy-dom/src/config/HTMLElementConfig.ts index 76fb7d69a..51536ee34 100644 --- a/packages/happy-dom/src/config/HTMLElementConfig.ts +++ b/packages/happy-dom/src/config/HTMLElementConfig.ts @@ -13,6 +13,7 @@ export default < permittedParents?: string[]; addPermittedParent?: string; moveForbiddenDescendant?: { exclude: string[] }; + escapesSVGNamespace?: boolean; }; } >{ @@ -46,7 +47,8 @@ export default < }, b: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, base: { className: 'HTMLBaseElement', @@ -62,7 +64,8 @@ export default < }, body: { className: 'HTMLBodyElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, template: { className: 'HTMLTemplateElement', @@ -86,7 +89,8 @@ export default < }, img: { className: 'HTMLImageElement', - contentModel: HTMLElementConfigContentModelEnum.noDescendants + contentModel: HTMLElementConfigContentModelEnum.noDescendants, + escapesSVGNamespace: true }, link: { className: 'HTMLLinkElement', @@ -106,15 +110,18 @@ export default < }, meta: { className: 'HTMLMetaElement', - contentModel: HTMLElementConfigContentModelEnum.noDescendants + contentModel: HTMLElementConfigContentModelEnum.noDescendants, + escapesSVGNamespace: true }, blockquote: { className: 'HTMLQuoteElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, br: { className: 'HTMLBRElement', - contentModel: HTMLElementConfigContentModelEnum.noDescendants + contentModel: HTMLElementConfigContentModelEnum.noDescendants, + escapesSVGNamespace: true }, button: { className: 'HTMLButtonElement', @@ -134,7 +141,8 @@ export default < }, code: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, col: { className: 'HTMLTableColElement', @@ -157,7 +165,8 @@ export default < dd: { className: 'HTMLElement', contentModel: HTMLElementConfigContentModelEnum.noForbiddenFirstLevelDescendants, - forbiddenDescendants: ['dt', 'dd'] + forbiddenDescendants: ['dt', 'dd'], + escapesSVGNamespace: true }, del: { className: 'HTMLModElement', @@ -177,24 +186,29 @@ export default < }, div: { className: 'HTMLDivElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, dl: { className: 'HTMLDListElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, dt: { className: 'HTMLElement', contentModel: HTMLElementConfigContentModelEnum.noForbiddenFirstLevelDescendants, - forbiddenDescendants: ['dt', 'dd'] + forbiddenDescendants: ['dt', 'dd'], + escapesSVGNamespace: true }, em: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, embed: { className: 'HTMLEmbedElement', - contentModel: HTMLElementConfigContentModelEnum.noDescendants + contentModel: HTMLElementConfigContentModelEnum.noDescendants, + escapesSVGNamespace: true }, fieldset: { className: 'HTMLFieldSetElement', @@ -214,31 +228,38 @@ export default < }, h1: { className: 'HTMLHeadingElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants, + escapesSVGNamespace: true }, h2: { className: 'HTMLHeadingElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants, + escapesSVGNamespace: true }, h3: { className: 'HTMLHeadingElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants, + escapesSVGNamespace: true }, h4: { className: 'HTMLHeadingElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants, + escapesSVGNamespace: true }, h5: { className: 'HTMLHeadingElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants, + escapesSVGNamespace: true }, h6: { className: 'HTMLHeadingElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants, + escapesSVGNamespace: true }, head: { className: 'HTMLHeadElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, header: { className: 'HTMLElement', @@ -250,7 +271,8 @@ export default < }, hr: { className: 'HTMLHRElement', - contentModel: HTMLElementConfigContentModelEnum.noDescendants + contentModel: HTMLElementConfigContentModelEnum.noDescendants, + escapesSVGNamespace: true }, html: { className: 'HTMLHtmlElement', @@ -258,7 +280,8 @@ export default < }, i: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, iframe: { className: 'HTMLIFrameElement', @@ -278,7 +301,8 @@ export default < }, li: { className: 'HTMLLIElement', - contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants + contentModel: HTMLElementConfigContentModelEnum.noFirstLevelSelfDescendants, + escapesSVGNamespace: true }, main: { className: 'HTMLElement', @@ -294,7 +318,8 @@ export default < }, menu: { className: 'HTMLMenuElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, meter: { className: 'HTMLMeterElement', @@ -314,7 +339,8 @@ export default < }, ol: { className: 'HTMLOListElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, optgroup: { className: 'HTMLOptGroupElement', @@ -331,7 +357,8 @@ export default < }, p: { className: 'HTMLParagraphElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, param: { className: 'HTMLParamElement', @@ -343,7 +370,8 @@ export default < }, pre: { className: 'HTMLPreElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, progress: { className: 'HTMLProgressElement', @@ -373,11 +401,13 @@ export default < }, ruby: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, s: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, samp: { className: 'HTMLElement', @@ -393,7 +423,8 @@ export default < }, small: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, source: { className: 'HTMLSourceElement', @@ -401,15 +432,18 @@ export default < }, span: { className: 'HTMLSpanElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, strong: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, sub: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, summary: { className: 'HTMLElement', @@ -417,13 +451,15 @@ export default < }, sup: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, table: { className: 'HTMLTableElement', contentModel: HTMLElementConfigContentModelEnum.permittedDescendants, permittedDescendants: ['caption', 'colgroup', 'thead', 'tfoot', 'tbody'], - moveForbiddenDescendant: { exclude: [] } + moveForbiddenDescendant: { exclude: [] }, + escapesSVGNamespace: true }, tbody: { className: 'HTMLTableSectionElement', @@ -480,15 +516,18 @@ export default < }, u: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, ul: { className: 'HTMLUListElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, var: { className: 'HTMLElement', - contentModel: HTMLElementConfigContentModelEnum.anyDescendants + contentModel: HTMLElementConfigContentModelEnum.anyDescendants, + escapesSVGNamespace: true }, video: { className: 'HTMLVideoElement', diff --git a/packages/happy-dom/src/config/MathMLElementConfig.ts b/packages/happy-dom/src/config/MathMLElementConfig.ts new file mode 100644 index 000000000..7da8f59e3 --- /dev/null +++ b/packages/happy-dom/src/config/MathMLElementConfig.ts @@ -0,0 +1,44 @@ +/** + * @see https://w3c.github.io/mathml-core/ + * @see https://developer.mozilla.org/en-US/docs/Web/MathML/Element + */ +export default < + { + [key: string]: boolean; + } +>{ + 'annotation-xml': true, + annotation: true, + maction: true, + math: true, + menclose: true, + merror: true, + mfenced: true, + mfrac: true, + mglyph: true, + mi: true, + mlabeledtr: true, + mmultiscripts: true, + mn: true, + mo: true, + mover: true, + mpadded: true, + mphantom: true, + mprescripts: true, + mroot: true, + mrow: true, + ms: true, + mspace: true, + msqrt: true, + mstyle: true, + msub: true, + msubsup: true, + msup: true, + mtable: true, + mtd: true, + mtext: true, + mtr: true, + munder: true, + munderover: true, + semantics: true +}; diff --git a/packages/happy-dom/src/html-parser/HTMLParser.ts b/packages/happy-dom/src/html-parser/HTMLParser.ts index c5f2ab463..31b41455d 100755 --- a/packages/happy-dom/src/html-parser/HTMLParser.ts +++ b/packages/happy-dom/src/html-parser/HTMLParser.ts @@ -17,6 +17,7 @@ import HTMLBodyElement from '../nodes/html-body-element/HTMLBodyElement.js'; import HTMLHtmlElement from '../nodes/html-html-element/HTMLHtmlElement.js'; import XMLEncodeUtility from '../utilities/XMLEncodeUtility.js'; import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; +import MathMLElementConfig from '../config/MathMLElementConfig.js'; /** * Markup RegExp. @@ -434,12 +435,18 @@ export default class HTMLParser { const tagName = this.nextElement[PropertySymbol.tagName]; const lowerTagName = tagName.toLowerCase(); - const config = HTMLElementConfig[lowerTagName]; + const config = + this.nextElement[PropertySymbol.namespaceURI] === NamespaceURI.html + ? HTMLElementConfig[lowerTagName] + : null; let previousCurrentNode: Node | null = null; while (previousCurrentNode !== this.rootNode) { const parentLowerTagName = this.currentNode[PropertySymbol.tagName]?.toLowerCase(); - const parentConfig = HTMLElementConfig[parentLowerTagName]; + const parentConfig = + this.currentNode[PropertySymbol.namespaceURI] === NamespaceURI.html + ? HTMLElementConfig[parentLowerTagName] + : null; if (previousCurrentNode === this.currentNode) { throw new Error( @@ -513,17 +520,7 @@ export default class HTMLParser { config?.contentModel === HTMLElementConfigContentModelEnum.noSelfDescendants && this.tagNameStack.includes(tagName) ) { - while (this.currentNode !== this.rootNode) { - if ((this.currentNode)[PropertySymbol.tagName] === tagName) { - this.nodeStack.pop(); - this.tagNameStack.pop(); - this.currentNode = this.nodeStack[this.nodeStack.length - 1] || this.rootNode; - break; - } - this.nodeStack.pop(); - this.tagNameStack.pop(); - this.currentNode = this.nodeStack[this.nodeStack.length - 1] || this.rootNode; - } + this.parseEndTag(tagName); } else if ( config?.permittedParents && !config.permittedParents.includes(parentLowerTagName) @@ -746,7 +743,10 @@ export default class HTMLParser { * @param text Text. */ private parseRawTextElementContent(tagName: string, text: string): void { - const upperTagName = StringUtility.asciiUpperCase(tagName); + const upperTagName = + this.currentNode[PropertySymbol.namespaceURI] === NamespaceURI.html + ? StringUtility.asciiUpperCase(tagName) + : tagName; if (upperTagName !== this.currentNode[PropertySymbol.tagName]) { return; @@ -800,19 +800,43 @@ export default class HTMLParser { private getStartTagElement(tagName: string): Element { const lowerTagName = StringUtility.asciiLowerCase(tagName); - // NamespaceURI is inherited from the parent element. + // Namespace URI is inherited from the parent element. const namespaceURI = this.currentNode[PropertySymbol.namespaceURI]; - // NamespaceURI should be SVG when the tag name is "svg" (even in XML mode). + // Namespace URI should be MathML when the tag name is "math". + if (lowerTagName === 'math' || namespaceURI === NamespaceURI.mathML) { + // is a special case where children of it can escape the Math ML namespace if it isn't a known Math ML element. + if (this.currentNode[PropertySymbol.tagName] === 'mi' && !MathMLElementConfig[lowerTagName]) { + if (lowerTagName === 'svg') { + return this.rootDocument.createElementNS(NamespaceURI.svg, 'svg'); + } + return this.rootDocument.createElementNS(NamespaceURI.html, lowerTagName); + } + return this.rootDocument.createElementNS(NamespaceURI.mathML, tagName); + } + + // Namespace URI should be SVG when the tag name is "svg". if (lowerTagName === 'svg') { return this.rootDocument.createElementNS(NamespaceURI.svg, 'svg'); } if (namespaceURI === NamespaceURI.svg) { - return this.rootDocument.createElementNS( - NamespaceURI.svg, - SVGElementConfig[lowerTagName]?.localName || tagName - ); + // is a special case where children of it escapes the SVG namespace. + // @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject + if (this.currentNode[PropertySymbol.tagName] === 'foreignObject') { + return this.rootDocument.createElementNS(NamespaceURI.html, lowerTagName); + } + const config = SVGElementConfig[lowerTagName]; + if (config) { + return this.rootDocument.createElementNS(NamespaceURI.svg, config.localName); + } + // Some HTML tags escapes the SVG namespace + // We should then end all SVG elements up to the first matching tag + if (HTMLElementConfig[lowerTagName]?.escapesSVGNamespace) { + this.parseEndTag('svg'); + } else { + return this.rootDocument.createElementNS(NamespaceURI.svg, tagName); + } } // New element. diff --git a/packages/happy-dom/src/html-serializer/HTMLSerializer.ts b/packages/happy-dom/src/html-serializer/HTMLSerializer.ts index dede1814a..94ed8352e 100644 --- a/packages/happy-dom/src/html-serializer/HTMLSerializer.ts +++ b/packages/happy-dom/src/html-serializer/HTMLSerializer.ts @@ -10,6 +10,8 @@ import ShadowRoot from '../nodes/shadow-root/ShadowRoot.js'; import HTMLElementConfig from '../config/HTMLElementConfig.js'; import HTMLElementConfigContentModelEnum from '../config/HTMLElementConfigContentModelEnum.js'; import XMLEncodeUtility from '../utilities/XMLEncodeUtility.js'; +import XMLSerializer from '../xml-serializer/XMLSerializer.js'; +import NamespaceURI from '../config/NamespaceURI.js'; /** * Serializes a node into HTML. @@ -60,6 +62,10 @@ export default class HTMLSerializer { * @returns Result. */ public serializeToString(root: Node): string { + const document = root[PropertySymbol.ownerDocument] || root; + if (document[PropertySymbol.contentType] !== 'text/html') { + return new XMLSerializer().serializeToString(root); + } switch (root[PropertySymbol.nodeType]) { case NodeTypeEnum.elementNode: const element = root; @@ -68,7 +74,10 @@ export default class HTMLSerializer { const config = HTMLElementConfig[element[PropertySymbol.localName]]; const tagName = prefix ? `${prefix}:${localName}` : localName; - if (config?.contentModel === HTMLElementConfigContentModelEnum.noDescendants) { + if ( + element[PropertySymbol.namespaceURI] === NamespaceURI.html && + config?.contentModel === HTMLElementConfigContentModelEnum.noDescendants + ) { return `<${tagName}${this.getAttributes(element)}>`; } @@ -94,7 +103,7 @@ export default class HTMLSerializer { } const childNodes = - tagName === 'template' + element[PropertySymbol.namespaceURI] === NamespaceURI.html && tagName === 'template' ? ((root).content)[PropertySymbol.nodeArray] : (root)[PropertySymbol.nodeArray]; diff --git a/packages/happy-dom/src/nodes/attr/Attr.ts b/packages/happy-dom/src/nodes/attr/Attr.ts index eccdfa2a7..8a23511fc 100644 --- a/packages/happy-dom/src/nodes/attr/Attr.ts +++ b/packages/happy-dom/src/nodes/attr/Attr.ts @@ -21,6 +21,13 @@ export default class Attr extends Node implements Attr { public [PropertySymbol.specified] = true; public [PropertySymbol.ownerElement]: Element | null = null; + /** + * @override + */ + public override get nodeName(): string { + return this[PropertySymbol.name]; + } + /** * Returns specified. * diff --git a/packages/happy-dom/src/nodes/comment/Comment.ts b/packages/happy-dom/src/nodes/comment/Comment.ts index 87a4f8bc1..d671152d0 100644 --- a/packages/happy-dom/src/nodes/comment/Comment.ts +++ b/packages/happy-dom/src/nodes/comment/Comment.ts @@ -10,11 +10,9 @@ export default class Comment extends CharacterData { public declare cloneNode: (deep?: boolean) => Comment; /** - * Node name. - * - * @returns Node name. + * @override */ - public get nodeName(): string { + public override get nodeName(): string { return '#comment'; } diff --git a/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts b/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts index ed45c25e8..23477d786 100644 --- a/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts +++ b/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts @@ -93,6 +93,13 @@ export default class DocumentFragment extends Node { } } + /** + * @override + */ + public override get nodeName(): string { + return '#document-fragment'; + } + /** * Inserts a set of Node objects or DOMString objects after the last child of the ParentNode. DOMString objects are inserted as equivalent Text nodes. * diff --git a/packages/happy-dom/src/nodes/document-type/DocumentType.ts b/packages/happy-dom/src/nodes/document-type/DocumentType.ts index 8b7903159..e49576e44 100644 --- a/packages/happy-dom/src/nodes/document-type/DocumentType.ts +++ b/packages/happy-dom/src/nodes/document-type/DocumentType.ts @@ -40,12 +40,10 @@ export default class DocumentType extends Node { } /** - * Node name. - * - * @returns Node name. + * @override */ - public get nodeName(): string { - return this.name; + public override get nodeName(): string { + return this[PropertySymbol.name]; } /** diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index 7092f9bba..90bfc41eb 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -196,6 +196,13 @@ export default class Document extends Node { public onpaste: (event: Event) => void = null; public onbeforematch: (event: Event) => void = null; + /** + * @override + */ + public override get nodeName(): string { + return '#document'; + } + /** * Returns adopted style sheets. * @@ -395,15 +402,6 @@ export default class Document extends Node { ]); } - /** - * Node name. - * - * @returns Node name. - */ - public get nodeName(): string { - return '#document'; - } - /** * Returns element. * @@ -433,10 +431,8 @@ export default class Document extends Node { * @returns Element. */ public get body(): HTMLBodyElement { - const documentElement = this.documentElement; - return documentElement - ? ParentNodeUtility.getElementByTagName(documentElement, 'body') - : null; + // Returns null for XMLDocument. + return null; } /** @@ -445,10 +441,8 @@ export default class Document extends Node { * @returns Element. */ public get head(): HTMLHeadElement { - const documentElement = this.documentElement; - return documentElement - ? ParentNodeUtility.getElementByTagName(documentElement, 'head') - : null; + // Returns null for XMLDocument. + return null; } /** @@ -1072,16 +1066,30 @@ export default class Document extends Node { svgElement[PropertySymbol.isValue] = options && options.is ? String(options.is) : null; return svgElement; + case NamespaceURI.mathML: + const mathMLElement = NodeFactory.createNode(this, window.MathMLElement); + + mathMLElement[PropertySymbol.tagName] = qualifiedName; + mathMLElement[PropertySymbol.localName] = localName; + mathMLElement[PropertySymbol.prefix] = prefix; + mathMLElement[PropertySymbol.namespaceURI] = namespaceURI; + mathMLElement[PropertySymbol.isValue] = options && options.is ? String(options.is) : null; + + return mathMLElement; case NamespaceURI.html: // Custom HTML element // If a polyfill is used, [PropertySymbol.registry] may be undefined const customElementDefinition = window.customElements[PropertySymbol.registry]?.get( options && options.is ? String(options.is) : qualifiedName ); + const tagName = + this[PropertySymbol.contentType] === 'text/html' + ? StringUtility.asciiUpperCase(qualifiedName) + : qualifiedName; if (customElementDefinition) { const element = new customElementDefinition.elementClass(); - element[PropertySymbol.tagName] = StringUtility.asciiUpperCase(qualifiedName); + element[PropertySymbol.tagName] = tagName; element[PropertySymbol.localName] = localName; element[PropertySymbol.prefix] = prefix; element[PropertySymbol.namespaceURI] = namespaceURI; @@ -1097,7 +1105,7 @@ export default class Document extends Node { if (elementClass) { const element = NodeFactory.createNode(this, elementClass); - element[PropertySymbol.tagName] = StringUtility.asciiUpperCase(qualifiedName); + element[PropertySymbol.tagName] = tagName; element[PropertySymbol.localName] = localName; element[PropertySymbol.prefix] = prefix; element[PropertySymbol.namespaceURI] = namespaceURI; @@ -1113,7 +1121,7 @@ export default class Document extends Node { const unknownElement = NodeFactory.createNode(this, unknownElementClass); - unknownElement[PropertySymbol.tagName] = StringUtility.asciiUpperCase(qualifiedName); + unknownElement[PropertySymbol.tagName] = tagName; unknownElement[PropertySymbol.localName] = localName; unknownElement[PropertySymbol.prefix] = prefix; unknownElement[PropertySymbol.namespaceURI] = namespaceURI; diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 1c8990bbb..f1f9f2bdb 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -28,7 +28,6 @@ import MutationTypeEnum from '../../mutation-observer/MutationTypeEnum.js'; import NamespaceURI from '../../config/NamespaceURI.js'; import NodeList from '../node/NodeList.js'; import CSSStyleDeclaration from '../../css/declaration/CSSStyleDeclaration.js'; -import NamedNodeMapProxyFactory from './NamedNodeMapProxyFactory.js'; import NodeFactory from '../NodeFactory.js'; import HTMLSerializer from '../../html-serializer/HTMLSerializer.js'; import HTMLParser from '../../html-parser/HTMLParser.js'; @@ -95,7 +94,6 @@ export default class Element public [PropertySymbol.scrollTop] = 0; public [PropertySymbol.scrollLeft] = 0; public [PropertySymbol.attributes] = new NamedNodeMap(this); - public [PropertySymbol.attributesProxy]: NamedNodeMap | null = null; public [PropertySymbol.children]: HTMLCollection | null = null; public [PropertySymbol.computedStyle]: CSSStyleDeclaration | null = null; public declare [PropertySymbol.tagName]: string | null; @@ -212,12 +210,7 @@ export default class Element * @returns Attributes. */ public get attributes(): NamedNodeMap { - if (!this[PropertySymbol.attributesProxy]) { - this[PropertySymbol.attributesProxy] = NamedNodeMapProxyFactory.createProxy( - this[PropertySymbol.attributes] - ); - } - return this[PropertySymbol.attributesProxy]; + return this[PropertySymbol.attributes]; } /** @@ -296,9 +289,7 @@ export default class Element } /** - * Node name. - * - * @returns Node name. + * @override */ public get nodeName(): string { return this[PropertySymbol.tagName]; diff --git a/packages/happy-dom/src/nodes/element/NamedNodeMap.ts b/packages/happy-dom/src/nodes/element/NamedNodeMap.ts index 9b3be26f5..6937aac21 100644 --- a/packages/happy-dom/src/nodes/element/NamedNodeMap.ts +++ b/packages/happy-dom/src/nodes/element/NamedNodeMap.ts @@ -5,6 +5,7 @@ import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import Element from './Element.js'; import NamespaceURI from '../../config/NamespaceURI.js'; import StringUtility from '../../utilities/StringUtility.js'; +import ClassMethodBinder from '../../utilities/ClassMethodBinder.js'; /** * Named Node Map. @@ -29,6 +30,109 @@ export default class NamedNodeMap { */ constructor(ownerElement: Element) { this[PropertySymbol.ownerElement] = ownerElement; + + const methodBinder = new ClassMethodBinder(this, [NamedNodeMap]); + + return new Proxy(this, { + get: (target, property) => { + if (property === 'length') { + return target[PropertySymbol.namedItems].size; + } + if (property in target || typeof property === 'symbol') { + methodBinder.bind(property); + return target[property]; + } + const index = Number(property); + if (!isNaN(index)) { + return Array.from(target[PropertySymbol.namedItems].values())[index]?.[0]; + } + return target.getNamedItem(property) || undefined; + }, + set(target, property, newValue): boolean { + methodBinder.bind(property); + if (typeof property === 'symbol') { + target[property] = newValue; + return true; + } + const index = Number(property); + if (isNaN(index)) { + target[property] = newValue; + } + return true; + }, + deleteProperty(target, property): boolean { + if (typeof property === 'symbol') { + delete target[property]; + return true; + } + const index = Number(property); + if (isNaN(index)) { + delete target[property]; + } + return true; + }, + ownKeys(target): string[] { + const keys = Array.from(target[PropertySymbol.namedItems].keys()); + for (let i = 0, max = target[PropertySymbol.namedItems].size; i < max; i++) { + keys.push(String(i)); + } + return keys; + }, + has(target, property): boolean { + if (typeof property === 'symbol') { + return false; + } + + if (property in target || target[PropertySymbol.namedItems].has(property)) { + return true; + } + + const index = Number(property); + + if (!isNaN(index) && index >= 0 && index < target[PropertySymbol.namedItems].size) { + return true; + } + + return false; + }, + defineProperty(target, property, descriptor): boolean { + methodBinder.preventBinding(property); + + if (property in target) { + Object.defineProperty(target, property, descriptor); + return true; + } + + return false; + }, + getOwnPropertyDescriptor(target, property): PropertyDescriptor { + if (property in target || typeof property === 'symbol') { + return; + } + + const index = Number(property); + + if (!isNaN(index) && index >= 0 && index < target[PropertySymbol.namedItems].size) { + return { + value: Array.from(target[PropertySymbol.namedItems].values())[index][0], + writable: false, + enumerable: true, + configurable: true + }; + } + + const namedItems = target[PropertySymbol.namedItems].get(property); + + if (namedItems) { + return { + value: namedItems[0], + writable: false, + enumerable: true, + configurable: true + }; + } + } + }); } /** diff --git a/packages/happy-dom/src/nodes/html-document/HTMLDocument.ts b/packages/happy-dom/src/nodes/html-document/HTMLDocument.ts index f08b4fffc..dfc510fe5 100644 --- a/packages/happy-dom/src/nodes/html-document/HTMLDocument.ts +++ b/packages/happy-dom/src/nodes/html-document/HTMLDocument.ts @@ -2,6 +2,9 @@ import Document from '../document/Document.js'; import * as PropertySymbol from '../../PropertySymbol.js'; import Node from '../node/Node.js'; import NodeTypeEnum from '../node/NodeTypeEnum.js'; +import HTMLBodyElement from '../html-body-element/HTMLBodyElement.js'; +import ParentNodeUtility from '../parent-node/ParentNodeUtility.js'; +import HTMLHeadElement from '../html-head-element/HTMLHeadElement.js'; /** * Document. @@ -24,6 +27,30 @@ export default class HTMLDocument extends Document { documentElement.appendChild(bodyElement); } + /** + * Returns element. + * + * @returns Element. + */ + public get body(): HTMLBodyElement { + const documentElement = this.documentElement; + return documentElement + ? ParentNodeUtility.getElementByTagName(documentElement, 'body') + : null; + } + + /** + * Returns element. + * + * @returns Element. + */ + public get head(): HTMLHeadElement { + const documentElement = this.documentElement; + return documentElement + ? ParentNodeUtility.getElementByTagName(documentElement, 'head') + : null; + } + /** * @override */ diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts b/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts index 4d564aaf5..65e69d116 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts @@ -1,6 +1,7 @@ import FocusEvent from '../../event/events/FocusEvent.js'; import * as PropertySymbol from '../../PropertySymbol.js'; import HTMLElement from '../html-element/HTMLElement.js'; +import MathMLElement from '../math-ml-element/MathMLElement.js'; import SVGElement from '../svg-element/SVGElement.js'; /** @@ -12,7 +13,7 @@ export default class HTMLElementUtility { * * @param element Element. */ - public static blur(element: HTMLElement | SVGElement): void { + public static blur(element: HTMLElement | SVGElement | MathMLElement): void { const target = element[PropertySymbol.proxy] || element; const document = target[PropertySymbol.ownerDocument]; @@ -49,7 +50,7 @@ export default class HTMLElementUtility { * * @param element Element. */ - public static focus(element: HTMLElement | SVGElement): void { + public static focus(element: HTMLElement | SVGElement | MathMLElement): void { const target = element[PropertySymbol.proxy] || element; const document = target[PropertySymbol.ownerDocument]; diff --git a/packages/happy-dom/src/nodes/math-ml-element/MathMLElement.ts b/packages/happy-dom/src/nodes/math-ml-element/MathMLElement.ts new file mode 100644 index 000000000..f0d92b019 --- /dev/null +++ b/packages/happy-dom/src/nodes/math-ml-element/MathMLElement.ts @@ -0,0 +1,81 @@ +import CSSStyleDeclaration from '../../css/declaration/CSSStyleDeclaration.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import Element from '../element/Element.js'; +import HTMLElementUtility from '../html-element/HTMLElementUtility.js'; +import DOMStringMap from '../../dom/DOMStringMap.js'; + +/** + * Math ML Element. + * + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/API/MathMLElement. + */ +export default class MathMLElement extends Element { + // Internal properties + public [PropertySymbol.style]: CSSStyleDeclaration | null = null; + + // Private properties + #dataset: DOMStringMap | null = null; + + /** + * Returns data set. + * + * @returns Data set. + */ + public get dataset(): DOMStringMap { + return (this.#dataset ??= new DOMStringMap(PropertySymbol.illegalConstructor, this)); + } + + /** + * Returns style. + * + * @returns Style. + */ + public get style(): CSSStyleDeclaration { + if (!this[PropertySymbol.style]) { + this[PropertySymbol.style] = new CSSStyleDeclaration( + PropertySymbol.illegalConstructor, + this[PropertySymbol.window], + { element: this } + ); + } + return this[PropertySymbol.style]; + } + + /** + * Returns tab index. + * + * @returns Tab index. + */ + public get tabIndex(): number { + const tabIndex = this.getAttribute('tabindex'); + return tabIndex !== null ? Number(tabIndex) : -1; + } + + /** + * Returns tab index. + * + * @param tabIndex Tab index. + */ + public set tabIndex(tabIndex: number) { + if (tabIndex === -1) { + this.removeAttribute('tabindex'); + } else { + this.setAttribute('tabindex', String(tabIndex)); + } + } + + /** + * Triggers a blur event. + */ + public blur(): void { + HTMLElementUtility.blur(this); + } + + /** + * Triggers a focus event. + */ + public focus(): void { + HTMLElementUtility.focus(this); + } +} diff --git a/packages/happy-dom/src/nodes/node/NodeUtility.ts b/packages/happy-dom/src/nodes/node/NodeUtility.ts index 8b5a2c323..4825aea6b 100644 --- a/packages/happy-dom/src/nodes/node/NodeUtility.ts +++ b/packages/happy-dom/src/nodes/node/NodeUtility.ts @@ -77,8 +77,8 @@ export default class NodeUtility { parent = parent[PropertySymbol.parentNode] ? parent[PropertySymbol.parentNode] : includeShadowRoots && (parent).host - ? (parent).host - : null; + ? (parent).host + : null; } return false; @@ -139,7 +139,38 @@ export default class NodeUtility { } /** - * Returns boolean indicating if nodeB is following nodeA in the document tree. + * Returns preceding node. + * + * Based on: + * https://github.com/jsdom/js-symbol-tree/blob/master/lib/SymbolTree.js#L185 + * + * @param node Node. + * @param [root] Root. + * @returns Following node. + */ + public static preceding(node: Node, root?: Node): Node { + if (node === root) { + return null; + } + + const previousSibling = node.previousSibling; + + if (previousSibling) { + let lastChild; + let current = node; + + while ((lastChild = node.lastChild)) { + current = lastChild; + } + + return current; + } + + return node[PropertySymbol.parentNode]; + } + + /** + * Returns following node. * * Based on: * https://github.com/jsdom/js-symbol-tree/blob/master/lib/SymbolTree.js#L220 diff --git a/packages/happy-dom/src/nodes/processing-instruction/ProcessingInstruction.ts b/packages/happy-dom/src/nodes/processing-instruction/ProcessingInstruction.ts index 09321f0af..c44492b31 100644 --- a/packages/happy-dom/src/nodes/processing-instruction/ProcessingInstruction.ts +++ b/packages/happy-dom/src/nodes/processing-instruction/ProcessingInstruction.ts @@ -11,6 +11,13 @@ export default class ProcessingInstruction extends CharacterData { public [PropertySymbol.nodeType] = NodeTypeEnum.processingInstructionNode; public [PropertySymbol.target]: string; + /** + * @override + */ + public override get nodeName(): string { + return this[PropertySymbol.target]; + } + /** * Returns target. * diff --git a/packages/happy-dom/src/nodes/text/Text.ts b/packages/happy-dom/src/nodes/text/Text.ts index 84d0ea4bd..e747c923e 100644 --- a/packages/happy-dom/src/nodes/text/Text.ts +++ b/packages/happy-dom/src/nodes/text/Text.ts @@ -15,11 +15,9 @@ export default class Text extends CharacterData { public override [PropertySymbol.styleNode]: HTMLStyleElement | null = null; /** - * Node name. - * - * @returns Node name. + * @override */ - public get nodeName(): string { + public override get nodeName(): string { return '#text'; } diff --git a/packages/happy-dom/src/tree-walker/NodeFilterUtility.ts b/packages/happy-dom/src/tree-walker/NodeFilterUtility.ts new file mode 100644 index 000000000..5eec0d9c6 --- /dev/null +++ b/packages/happy-dom/src/tree-walker/NodeFilterUtility.ts @@ -0,0 +1,44 @@ +import NodeFilterMask from './NodeFilterMask.js'; +import * as PropertySymbol from '../PropertySymbol.js'; +import Node from '../nodes/node/Node.js'; +import NodeFilter from './NodeFilter.js'; +import INodeFilter from './INodeFilter.js'; + +/** + * + */ +export default class NodeFilterUtility { + /** + * Filters a node. + * + * Based on solution: + * https://gist.github.com/shawndumas/1132009. + * + * @param node Node. + * @param options Filter options. + * @param options.whatToShow What to show. + * @param options.filter Filter. + * @returns Filter result. + */ + public static filterNode( + node: Node, + options: { + whatToShow: number; + filter: INodeFilter | null; + } + ): number { + const mask = NodeFilterMask[node[PropertySymbol.nodeType]]; + + if (mask && (options.whatToShow & mask) == 0) { + return NodeFilter.FILTER_SKIP; + } + if (typeof options.filter === 'function') { + return options.filter(node); + } + if (options.filter) { + return options.filter.acceptNode(node); + } + + return NodeFilter.FILTER_ACCEPT; + } +} diff --git a/packages/happy-dom/src/tree-walker/NodeIterator.ts b/packages/happy-dom/src/tree-walker/NodeIterator.ts index 50eadbb8b..6188e81dc 100644 --- a/packages/happy-dom/src/tree-walker/NodeIterator.ts +++ b/packages/happy-dom/src/tree-walker/NodeIterator.ts @@ -1,8 +1,8 @@ import INodeFilter from './INodeFilter.js'; -import TreeWalker from './TreeWalker.js'; import Node from '../nodes/node/Node.js'; -import * as PropertySymbol from '../PropertySymbol.js'; import NodeFilter from './NodeFilter.js'; +import NodeFilterUtility from './NodeFilterUtility.js'; +import * as PropertySymbol from '../PropertySymbol.js'; /** * The NodeIterator object represents the nodes of a document subtree and a position within them. @@ -11,11 +11,17 @@ import NodeFilter from './NodeFilter.js'; * https://developer.mozilla.org/en-US/docs/Web/API/NodeIterator */ export default class NodeIterator { - #root: Node = null; - #whatToShow = -1; - #filter: INodeFilter = null; - #walker: TreeWalker; - #atRoot = true; + #root: Node; + #referenceNode: Node; + #filterOptions: { + whatToShow: number; + filter: INodeFilter | null; + } = { + whatToShow: -1, + filter: null + }; + #index = -1; + #pointerBeforeReferenceNode = true; /** * Constructor. @@ -26,9 +32,9 @@ export default class NodeIterator { */ constructor(root: Node, whatToShow = -1, filter: INodeFilter = null) { this.#root = root; - this.#whatToShow = whatToShow; - this.#filter = filter; - this.#walker = new TreeWalker(root, whatToShow, filter); + this.#referenceNode = root; + this.#filterOptions.whatToShow = whatToShow; + this.#filterOptions.filter = filter; } /** @@ -46,7 +52,7 @@ export default class NodeIterator { * @returns What to show. */ public get whatToShow(): number { - return this.#whatToShow; + return this.#filterOptions.whatToShow; } /** @@ -55,7 +61,25 @@ export default class NodeIterator { * @returns Filter. */ public get filter(): INodeFilter { - return this.#filter; + return this.#filterOptions.filter; + } + + /** + * Returns reference node. + * + * @returns Reference node. + */ + public get referenceNode(): Node | null { + return this.#referenceNode || null; + } + + /** + * Returns pointer before reference node. + * + * @returns Pointer before reference node. + */ + public get pointerBeforeReferenceNode(): boolean { + return this.#pointerBeforeReferenceNode; } /** @@ -64,14 +88,27 @@ export default class NodeIterator { * @returns Current node. */ public nextNode(): Node { - if (this.#atRoot) { - this.#atRoot = false; - if (this.#walker[PropertySymbol.filterNode](this.#root) !== NodeFilter.FILTER_ACCEPT) { - return this.#walker.nextNode(); + const nodes = this.#getNodes(this.#root); + + // If the current node has been removed we need to step back one node. + if (this.#index !== -1 && this.#referenceNode !== nodes[this.#index]) { + this.#index--; + } + + this.#pointerBeforeReferenceNode = false; + + while (this.#index < nodes.length - 1) { + this.#index++; + + const node = nodes[this.#index]; + + if (NodeFilterUtility.filterNode(node, this.#filterOptions) === NodeFilter.FILTER_ACCEPT) { + this.#referenceNode = node; + return node; } - return this.#root; } - return this.#walker.nextNode(); + + return null; } /** @@ -80,6 +117,51 @@ export default class NodeIterator { * @returns Current node. */ public previousNode(): Node { - return this.#walker.previousNode(); + const nodes = this.#getNodes(this.#root); + + if (this.#index !== -1 && this.#referenceNode !== nodes[this.#index]) { + this.#index++; + } + + this.#pointerBeforeReferenceNode = true; + + while (this.#index > 0) { + this.#index--; + + const node = nodes[this.#index]; + + if (NodeFilterUtility.filterNode(node, this.#filterOptions) === NodeFilter.FILTER_ACCEPT) { + this.#referenceNode = node; + return node; + } + } + + return null; + } + + /** + * This is a legacy method, and no longer has any effect. + * + * Previously it served to mark a NodeIterator as disposed, so it could be reclaimed by garbage collection. + */ + public detach(): void { + // Do nothing as per the spec. + } + + /** + * Returns the nodes of the iterator. + * + * @param root Root. + * @param [nodes] Nodes. + */ + #getNodes(root: Node, nodes?: Node[]): Node[] { + nodes = nodes || [root]; + + for (const node of root[PropertySymbol.nodeArray]) { + nodes.push(node); + this.#getNodes(node, nodes); + } + + return nodes; } } diff --git a/packages/happy-dom/src/tree-walker/TreeWalker.ts b/packages/happy-dom/src/tree-walker/TreeWalker.ts index f4a34b733..46126d983 100644 --- a/packages/happy-dom/src/tree-walker/TreeWalker.ts +++ b/packages/happy-dom/src/tree-walker/TreeWalker.ts @@ -1,18 +1,26 @@ import Node from '../nodes/node/Node.js'; import * as PropertySymbol from '../PropertySymbol.js'; import INodeFilter from './INodeFilter.js'; -import NodeFilterMask from './NodeFilterMask.js'; import DOMException from '../exception/DOMException.js'; import NodeFilter from './NodeFilter.js'; +import NodeFilterUtility from './NodeFilterUtility.js'; /** * The TreeWalker object represents the nodes of a document subtree and a position within them. */ export default class TreeWalker { - public root: Node = null; - public whatToShow = -1; - public filter: INodeFilter = null; - public currentNode: Node = null; + // Internal properties. + public [PropertySymbol.currentNode]: Node | null = null; + + // Private properties. + #root: Node; + #filterOptions: { + whatToShow: number; + filter: INodeFilter | null; + } = { + whatToShow: -1, + filter: null + }; /** * Constructor. @@ -26,10 +34,46 @@ export default class TreeWalker { throw new DOMException('Parameter 1 was not of type Node.'); } - this.root = root; - this.whatToShow = whatToShow; - this.filter = filter; - this.currentNode = root; + this.#root = root; + this.#filterOptions.whatToShow = whatToShow; + this.#filterOptions.filter = filter; + this[PropertySymbol.currentNode] = root; + } + + /** + * Returns root. + * + * @returns Root. + */ + public get root(): Node | null { + return this.#root; + } + + /** + * Returns what to show. + * + * @returns What to show. + */ + public get whatToShow(): number { + return this.#filterOptions.whatToShow; + } + + /** + * Returns filter. + * + * @returns Filter. + */ + public get filter(): INodeFilter { + return this.#filterOptions.filter; + } + + /** + * Returns current node. + * + * @returns Current node. + */ + public get currentNode(): Node { + return this[PropertySymbol.currentNode]; } /** @@ -40,7 +84,8 @@ export default class TreeWalker { public nextNode(): Node { if (!this.firstChild()) { while (!this.nextSibling() && this.parentNode()) {} - this.currentNode = this.currentNode === this.root ? null : this.currentNode || null; + this[PropertySymbol.currentNode] = + this.currentNode === this.root ? null : this.currentNode || null; } return this.currentNode; } @@ -52,7 +97,8 @@ export default class TreeWalker { */ public previousNode(): Node { while (!this.previousSibling() && this.parentNode()) {} - this.currentNode = this.currentNode === this.root ? null : this.currentNode || null; + this[PropertySymbol.currentNode] = + this.currentNode === this.root ? null : this.currentNode || null; return this.currentNode; } @@ -67,16 +113,19 @@ export default class TreeWalker { this.currentNode && this.currentNode[PropertySymbol.parentNode] ) { - this.currentNode = this.currentNode[PropertySymbol.parentNode]; + this[PropertySymbol.currentNode] = this.currentNode[PropertySymbol.parentNode]; - if (this[PropertySymbol.filterNode](this.currentNode) === NodeFilter.FILTER_ACCEPT) { + if ( + NodeFilterUtility.filterNode(this.currentNode, this.#filterOptions) === + NodeFilter.FILTER_ACCEPT + ) { return this.currentNode; } this.parentNode(); } - this.currentNode = null; + this[PropertySymbol.currentNode] = null; return null; } @@ -90,9 +139,12 @@ export default class TreeWalker { const childNodes = this.currentNode ? (this.currentNode)[PropertySymbol.nodeArray] : []; if (childNodes.length > 0) { - this.currentNode = childNodes[0]; + this[PropertySymbol.currentNode] = childNodes[0]; - if (this[PropertySymbol.filterNode](this.currentNode) === NodeFilter.FILTER_ACCEPT) { + if ( + NodeFilterUtility.filterNode(this.currentNode, this.#filterOptions) === + NodeFilter.FILTER_ACCEPT + ) { return this.currentNode; } @@ -111,9 +163,12 @@ export default class TreeWalker { const childNodes = this.currentNode ? (this.currentNode)[PropertySymbol.nodeArray] : []; if (childNodes.length > 0) { - this.currentNode = childNodes[childNodes.length - 1]; + this[PropertySymbol.currentNode] = childNodes[childNodes.length - 1]; - if (this[PropertySymbol.filterNode](this.currentNode) === NodeFilter.FILTER_ACCEPT) { + if ( + NodeFilterUtility.filterNode(this.currentNode, this.#filterOptions) === + NodeFilter.FILTER_ACCEPT + ) { return this.currentNode; } @@ -140,9 +195,12 @@ export default class TreeWalker { const index = siblings.indexOf(this.currentNode); if (index > 0) { - this.currentNode = siblings[index - 1]; + this[PropertySymbol.currentNode] = siblings[index - 1]; - if (this[PropertySymbol.filterNode](this.currentNode) === NodeFilter.FILTER_ACCEPT) { + if ( + NodeFilterUtility.filterNode(this.currentNode, this.#filterOptions) === + NodeFilter.FILTER_ACCEPT + ) { return this.currentNode; } @@ -170,9 +228,12 @@ export default class TreeWalker { const index = siblings.indexOf(this.currentNode); if (index + 1 < siblings.length) { - this.currentNode = siblings[index + 1]; + this[PropertySymbol.currentNode] = siblings[index + 1]; - if (this[PropertySymbol.filterNode](this.currentNode) === NodeFilter.FILTER_ACCEPT) { + if ( + NodeFilterUtility.filterNode(this.currentNode, this.#filterOptions) === + NodeFilter.FILTER_ACCEPT + ) { return this.currentNode; } @@ -182,29 +243,4 @@ export default class TreeWalker { return null; } - - /** - * Filters a node. - * - * Based on solution: - * https://gist.github.com/shawndumas/1132009. - * - * @param node Node. - * @returns Child nodes. - */ - public [PropertySymbol.filterNode](node: Node): number { - const mask = NodeFilterMask[node.nodeType]; - - if (mask && (this.whatToShow & mask) == 0) { - return NodeFilter.FILTER_SKIP; - } - if (typeof this.filter === 'function') { - return this.filter(node); - } - if (this.filter) { - return this.filter.acceptNode(node); - } - - return NodeFilter.FILTER_ACCEPT; - } } diff --git a/packages/happy-dom/src/window/BrowserWindow.ts b/packages/happy-dom/src/window/BrowserWindow.ts index e0722fd22..e73ad8589 100644 --- a/packages/happy-dom/src/window/BrowserWindow.ts +++ b/packages/happy-dom/src/window/BrowserWindow.ts @@ -307,6 +307,7 @@ import DOMPoint from '../dom/DOMPoint.js'; import SVGAnimatedLengthList from '../svg/SVGAnimatedLengthList.js'; import CustomElementReactionStack from '../custom-element/CustomElementReactionStack.js'; import IScrollToOptions from './IScrollToOptions.js'; +import MathMLElement from '../nodes/math-ml-element/MathMLElement.js'; const TIMER = { setTimeout: globalThis.setTimeout.bind(globalThis), @@ -439,6 +440,9 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal public readonly HTMLBodyElement = HTMLBodyElement; public readonly HTMLAreaElement = HTMLAreaElement; + // MathML Element classes + public readonly MathMLElement = MathMLElement; + // SVG Element classes public readonly SVGSVGElement = SVGSVGElement; public readonly SVGAnimateElement = SVGAnimateElement; @@ -573,6 +577,7 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal public readonly UserProximityEvent = Event; public readonly WebGLContextEvent = Event; public readonly TextEvent = Event; + public readonly NamedNodeMap = NamedNodeMap; // Other classes that has to be bound to the Window context (populated by WindowContextClassExtender) public declare readonly NodeIterator: typeof NodeIterator; @@ -606,7 +611,6 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal public declare readonly MediaStream: typeof MediaStream; public declare readonly MediaStreamTrack: typeof MediaStreamTrack; public declare readonly CanvasCaptureMediaStreamTrack: typeof CanvasCaptureMediaStreamTrack; - public declare readonly NamedNodeMap: typeof NamedNodeMap; public declare readonly TextTrack: typeof TextTrack; public declare readonly TextTrackList: typeof TextTrackList; public declare readonly TextTrackCue: typeof TextTrackCue; diff --git a/packages/happy-dom/src/window/WindowContextClassExtender.ts b/packages/happy-dom/src/window/WindowContextClassExtender.ts index eda66d859..d55e6cefd 100644 --- a/packages/happy-dom/src/window/WindowContextClassExtender.ts +++ b/packages/happy-dom/src/window/WindowContextClassExtender.ts @@ -45,7 +45,6 @@ import FileReaderImplementation from '../file/FileReader.js'; import MediaStreamImplementation from '../nodes/html-media-element/MediaStream.js'; import MediaStreamTrackImplementation from '../nodes/html-media-element/MediaStreamTrack.js'; import CanvasCaptureMediaStreamTrackImplementation from '../nodes/html-canvas-element/CanvasCaptureMediaStreamTrack.js'; -import NamedNodeMapImplementation from '../nodes/element/NamedNodeMap.js'; /** * Extends classes with a "window" property, so that they internally can access it's Window context. @@ -284,11 +283,6 @@ export default class WindowContextClassExtender { (window.CanvasCaptureMediaStreamTrack) = CanvasCaptureMediaStreamTrack; - // NamedNodeMap - class NamedNodeMap extends NamedNodeMapImplementation {} - NamedNodeMap.prototype[PropertySymbol.window] = window; - (window.NamedNodeMap) = NamedNodeMap; - /* eslint-enable jsdoc/require-jsdoc */ } } diff --git a/packages/happy-dom/test/dom-parser/DOMParser.test.ts b/packages/happy-dom/test/dom-parser/DOMParser.test.ts index 937f81b48..9b5ee2e96 100644 --- a/packages/happy-dom/test/dom-parser/DOMParser.test.ts +++ b/packages/happy-dom/test/dom-parser/DOMParser.test.ts @@ -142,7 +142,8 @@ describe('DOMParser', () => { `, 'application/xml' ); - expect(new HTMLSerializer().serializeToString(newDocument)).toBe(` + expect(new XMLSerializer().serializeToString(newDocument)) + .toBe(` Belgian Waffles $5.95 diff --git a/packages/happy-dom/test/html-parser/HTMLParser.test.ts b/packages/happy-dom/test/html-parser/HTMLParser.test.ts index a260b1975..7b28d4304 100644 --- a/packages/happy-dom/test/html-parser/HTMLParser.test.ts +++ b/packages/happy-dom/test/html-parser/HTMLParser.test.ts @@ -13,6 +13,11 @@ import CustomElement from '../CustomElement.js'; import HTMLHtmlElement from '../../src/nodes/html-html-element/HTMLHtmlElement.js'; import XMLSerializer from '../../src/xml-serializer/XMLSerializer.js'; import TreeWalkerHTML from '../tree-walker/data/TreeWalkerHTML.js'; +import MathMLElementConfig from '../../src/config/MathMLElementConfig.js'; +import SVGSVGElement from '../../src/nodes/svg-svg-element/SVGSVGElement.js'; +import HTMLElementConfig from '../../src/config/HTMLElementConfig.js'; +import SVGElementConfig from '../../src/config/SVGElementConfig.js'; +import HTMLElementConfigContentModelEnum from '../../src/config/HTMLElementConfigContentModelEnum.js'; describe('HTMLParser', () => { let window: Window; @@ -731,6 +736,143 @@ describe('HTMLParser', () => { ); }); + it('Parses malformed SVG elements with style and comments.', () => { + const result = new HTMLParser(window).parse( + `

\">`, + document.implementation.createHTMLDocument() + ); + + expect(new HTMLSerializer().serializeToString(result)).toBe( + `

` + ); + }); + + it('Children of SVGForeignObjectElement should escape the SVG namespace', () => { + const result = new HTMLParser(window).parse( + `
`, + document.implementation.createHTMLDocument() + ); + + expect(new HTMLSerializer().serializeToString(result)).toBe( + `
` + ); + + expect((result.querySelector('div')).namespaceURI).toBe(NamespaceURI.html); + expect((result.querySelector('style')).namespaceURI).toBe(NamespaceURI.html); + expect((result.querySelector('svg')).namespaceURI).toBe(NamespaceURI.svg); + }); + + it('It handles SVG element with unicode characters in tag name.', () => { + const result = new HTMLParser(window).parse( + `

foo
`, + document.implementation.createHTMLDocument() + ); + + expect(new HTMLSerializer().serializeToString(result)).toBe( + `
foo
` + ); + }); + + it('Handles HTML elements that should escape the SVG namespace.', () => { + for (const tag of Object.keys(HTMLElementConfig)) { + const config = HTMLElementConfig[tag]; + if (config.escapesSVGNamespace) { + const result = new HTMLParser(window).parse(`<${tag}>Test`); + if (tag === 'body' || tag === 'head' || tag === 'html') { + expect(new HTMLSerializer().serializeToString(result)).toBe(`Test`); + } else if (config.contentModel === HTMLElementConfigContentModelEnum.noDescendants) { + expect(new HTMLSerializer().serializeToString(result)).toBe(`<${tag}>Test`); + } else { + expect(new HTMLSerializer().serializeToString(result)).toBe( + `<${tag}>Test` + ); + } + } else { + const result = new HTMLParser(window).parse(`<${tag}>Test`); + expect(new HTMLSerializer().serializeToString(result)).toBe( + `<${tag}>Test` + ); + } + } + }); + + it('Parses MathML elements.', () => { + const result = new HTMLParser(window).parse( + `
+ + + x + + +
` + ); + + expect(new HTMLSerializer().serializeToString(result)).toBe( + `
+ + + x + + +
` + ); + + expect(result.children[0].children[0]).toBeInstanceOf(window.MathMLElement); + expect(result.children[0].children[0].namespaceURI).toBe(NamespaceURI.mathML); + expect(result.children[0].children[0].children[0]).toBeInstanceOf(window.MathMLElement); + expect(result.children[0].children[0].children[0].namespaceURI).toBe(NamespaceURI.mathML); + expect(result.children[0].children[0].children[0].children[0]).toBeInstanceOf( + window.MathMLElement + ); + expect(result.children[0].children[0].children[0].children[0].namespaceURI).toBe( + NamespaceURI.mathML + ); + expect(result.children[0].children[0].children[0].children[0].textContent).toBe('x'); + }); + + it("Escapes the MathML namespace in elements if it isn't a known Math ML element", () => { + const result = new HTMLParser(window).parse( + `
+ + + ${Object.keys(MathMLElementConfig) + .map((key) => `<${key}>`) + .join('')} + + + + + + +
` + ); + + const mi1 = result.children[0].children[0].children[0]; + + expect(mi1.children.length).toBe(Object.keys(MathMLElementConfig).length); + + for (const child of mi1.children) { + expect(child.namespaceURI).toBe(NamespaceURI.mathML); + } + + const mi2 = result.children[0].children[0].children[1]; + + expect(mi2.children.length).toBe(2); + expect(mi2.children[0].namespaceURI).toBe(NamespaceURI.svg); + expect(mi2.children[1].namespaceURI).toBe(NamespaceURI.html); + }); + + it('Parses malformed MathML elements.', () => { + const result = new HTMLParser(window).parse( + `
`, + document.implementation.createHTMLDocument() + ); + + expect(new HTMLSerializer().serializeToString(result)).toBe( + `
` + ); + }); + it('Parses childless elements with start and end tag names in different case', () => { const result = new HTMLParser(window).parse( ` diff --git a/packages/happy-dom/test/nodes/document/Document.test.ts b/packages/happy-dom/test/nodes/document/Document.test.ts index b59c27e35..9fbab7209 100644 --- a/packages/happy-dom/test/nodes/document/Document.test.ts +++ b/packages/happy-dom/test/nodes/document/Document.test.ts @@ -1130,6 +1130,18 @@ describe('Document', () => { expect(element).toBeInstanceOf(SVGElement); expect(element.constructor.name).toBe('SVGElement'); }); + + it('Returns MathMLElement when namespace is MathML.', () => { + const element = document.createElementNS(NamespaceURI.mathML, 'math'); + expect(element.tagName).toBe('math'); + expect(element.localName).toBe('math'); + expect(element).toBeInstanceOf(window.MathMLElement); + + const element2 = document.createElementNS(NamespaceURI.mathML, 'mglyph'); + expect(element2.tagName).toBe('mglyph'); + expect(element2.localName).toBe('mglyph'); + expect(element2).toBeInstanceOf(window.MathMLElement); + }); }); describe('createAttribute()', () => { diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts index d6fc95481..b4a631c42 100644 --- a/packages/happy-dom/test/nodes/element/Element.test.ts +++ b/packages/happy-dom/test/nodes/element/Element.test.ts @@ -349,7 +349,11 @@ describe('Element', () => { }); describe('get attributes()', () => { - it('Returns all attributes as an object.', () => { + it('Returns a NamedNodeMap object.', () => { + expect(element.attributes).toBeInstanceOf(window.NamedNodeMap); + }); + + it('Returns all attributes.', () => { element.setAttribute('key1', 'value1'); element.setAttribute('key2', 'value2'); element.setAttribute('key3', 'value3'); diff --git a/packages/happy-dom/test/tree-walker/NodeIterator.test.ts b/packages/happy-dom/test/tree-walker/NodeIterator.test.ts index 9619dea56..26f571839 100644 --- a/packages/happy-dom/test/tree-walker/NodeIterator.test.ts +++ b/packages/happy-dom/test/tree-walker/NodeIterator.test.ts @@ -65,6 +65,47 @@ describe('NodeIterator', () => { ]); }); + it('Walks into each node in the DOM tree when elements are removed while iterating.', () => { + const nodeIterator = document.createNodeIterator(document.body); + const html: string[] = []; + let currentNode; + + while ((currentNode = nodeIterator.nextNode())) { + if (currentNode.tagName === 'B') { + currentNode.remove(); + } + html.push(NODE_TO_STRING(currentNode)); + } + + // We expect that the text inside the tag is skipped as it is not connected to the DOM tree. + + expect(html).toEqual([ + '\n\t\t\t
\n\t\t\t\t\n\t\t\t\tBold\n\t\t\t\t\n\t\t\t\tSpan\n\t\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\tBold\n\t\t\t\t\n\t\t\t
\n\t\t\n\t', + '\n\t\t\t', + '
\n\t\t\t\t\n\t\t\t\tBold\n\t\t\t\t\n\t\t\t\tSpan\n\t\t\t
', + '\n\t\t\t\t', + '', + '\n\t\t\t\t', + 'Bold', + '\n\t\t\t\t', + '', + '\n\t\t\t\t', + 'Span', + 'Span', + '\n\t\t\t', + '\n\t\t\t', + '
\n\t\t\t\t\n\t\t\t\tBold\n\t\t\t\t\n\t\t\t
', + '\n\t\t\t\t', + '', + '\n\t\t\t\t', + 'Bold', + '\n\t\t\t\t', + '', + '\n\t\t\t', + '\n\t\t\n\t' + ]); + }); + it('Walks into each HTMLElement in the DOM tree when whatToShow is set to NodeFilter.SHOW_ELEMENT.', () => { const nodeIterator = document.createNodeIterator(document.body, NodeFilter.SHOW_ELEMENT); const html: string[] = []; diff --git a/packages/happy-dom/test/tree-walker/TreeWalker.test.ts b/packages/happy-dom/test/tree-walker/TreeWalker.test.ts index cdffc7828..4c6e3e998 100644 --- a/packages/happy-dom/test/tree-walker/TreeWalker.test.ts +++ b/packages/happy-dom/test/tree-walker/TreeWalker.test.ts @@ -65,6 +65,30 @@ describe('TreeWalker', () => { ]); }); + it('Walks into each node in the DOM tree while an element is removed.', () => { + const treeWalker = document.createTreeWalker(document.body); + const html: string[] = []; + let currentNode; + + while ((currentNode = treeWalker.nextNode())) { + if (currentNode.tagName === 'B') { + currentNode.remove(); + } + + html.push(NODE_TO_STRING(currentNode)); + } + + expect(html).toEqual([ + '\n\t\t\t', + '
\n\t\t\t\t\n\t\t\t\tBold\n\t\t\t\t\n\t\t\t\tSpan\n\t\t\t
', + '\n\t\t\t\t', + '', + '\n\t\t\t\t', + 'Bold', + 'Bold' + ]); + }); + it('Walks into each HTMLElement in the DOM tree when whatToShow is set to NodeFilter.SHOW_ELEMENT.', () => { const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); const html: string[] = []; diff --git a/packages/happy-dom/test/xml-parser/XMLParser.test.ts b/packages/happy-dom/test/xml-parser/XMLParser.test.ts index e3dbadb55..6d53b9371 100644 --- a/packages/happy-dom/test/xml-parser/XMLParser.test.ts +++ b/packages/happy-dom/test/xml-parser/XMLParser.test.ts @@ -187,8 +187,8 @@ describe('XMLParser', () => { expect((result.children[0].children[0]).textContent).toBe(''); expect((result.children[0].children[1]).textContent).toBe(''); - expect((result.children[0].children[0]).innerHTML).toBe(''); - expect((result.children[0].children[1]).innerHTML).toBe(''); + expect((result.children[0].children[0]).innerHTML).toBe(''); + expect((result.children[0].children[1]).innerHTML).toBe(''); }); it('Outputs error for incomplete end tag.', () => {