diff --git a/.gitignore b/.gitignore index c3204d8..8569a67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/.nyc_output/ +/coverage/ /dist/ /docs/ /lib/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7f2af3f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "prettier.printWidth": 120, + "prettier.singleQuote": true, + "prettier.useTabs": true +} diff --git a/README.md b/README.md index b62877b..2cae2b7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,82 @@ -# SlimDOM.js [![Build Status](https://travis-ci.org/bwrrp/slimdom.js.png?branch=master)](https://travis-ci.org/bwrrp/slimdom.js) - -Fast, tiny DOM implementation. +# slimdom [![Build Status](https://travis-ci.org/bwrrp/slimdom.js.png?branch=master)](https://travis-ci.org/bwrrp/slimdom.js) + +Fast, tiny DOM implementation for node and the browser. + +This is a (partial) implementation of the [DOM living standard][DOMSTANDARD], as last updated 15 June 2017. See the 'Features' and 'Limitations' sections below for details on what's included and what's not. + +[DOMSTANDARD]: https://dom.spec.whatwg.org/ + +## Installation + +The slimdom library can be installed using npm or yarn: +``` +npm install --save slimdom +``` +or +``` +yarn add slimdom +``` + +The package includes both a commonJS bundle (`dist/slimdom.js`) and an ES6 module (`dist/slimdom.mjs`). + +## Usage + +Create documents using the slimdom.Document constructor, and manipulate them using the [standard DOM API][1]. + +``` +import * as slimdom from 'slimdom'; + +const document = new slimdom.Document(); +document.appendChild(document.createElement('root')); +// ... +``` + +Some DOM API's, such as the `DocumentFragment` constructor, require the presence of a global document. In these cases, slimdom will use the instance exposed through `slimdom.document`. Although you could mutate this document, it is recommended to create your own to avoid conflicts with other code using slimdom in your application. + +When using a `Range`, make sure to call `detach` when you don't need it anymore. As JavaScript currently does not have a way to detect when the instance can be garbage collected, we don't have any other way of detecting when we can stop updating the range for mutations to the surrounding nodes. + +## Features + +This library implements: + +* All node types: `Attr`, `CDATASection`, `Comment`, `Document`, `DocumentFragment`, `DocumentType`, `Element`, `ProcessingInstruction`, `Text` and `XMLDocument`. +* `Range`, which correctly updates under mutations +* `MutationObserver` + +## Limitations + +The following features are not (yet) implemented: + +* No events, no `createEvent` on `Document` +* Arrays are used instead of `HTMLCollection` / `NodeList` and `NamedNodeMap`. +* No `getElementById` / `getElementsByTagName` / `getElementsByTagNameNS` / `getElementsByClassName` +* No `prepend` / `append` +* No selectors, no `querySelector` / `querySelectorAll` on `ParentNode`, no `closest` / `matches` / `webkitMatchesSelector` on `Element` +* No `before` / `after` / `replaceWith` / `remove` +* No `attributeFilter` for mutation observers +* No `baseURI` / `isConnected` / `getRootNode` / `textContent` / `isEqualNode` / `isSameNode` / `compareDocumentPosition` on `Node` +* No `URL` / `documentURI` / `origin` / `compatMode` / `characterSet` / `charset` / `inputEncoding` / `contentType` on `Document` +* No `hasFeature` on `DOMImplementation` +* No `id` / `className` / `classList` / `insertAdjacentElement` / `insertAdjacentText` on `Element` +* No `specified` on `Attr` +* No `wholeText` on `Text` +* No `deleteContents` / `extractContents` / `cloneContents` / `insertNode` / `surroundContents` on `Range` +* No `NodeIterator` / `TreeWalker` / `NodeFilter`, no `createNodeIterator` / `createTreeWalker` on `Document` +* No HTML documents, including `HTMLElement` and its subclasses. This also includes HTML casing behavior for attributes and tagNames. +* No shadow DOM, `Slotable` / `ShadowRoot`, no `slot` / `attachShadow` / `shadowRoot` on `Element` +* No custom elements +* No XML or HTML parsing / serialization, but see `test/SlimdomTreeAdapter.ts` for an example on how to connect the parse5 HTML parser. + +Do not rely on the behavior or presence of any methods and properties not specified in the DOM standard. For example, do not use JavaScript array methods exposed on properties that should expose a NodeList and do not use Element as a constructor. This behavior is *not* considered public API and may change without warning in a future release. + +## Contributing + +Pull requests for missing features or tests, bug reports, questions and other feedback are always welcome! Just [open an issue](https://github.com/bwrrp/slimdom.js/issues/new) on the github repo, and provide as much detail as you can. + +To work on the slimdom library itself, clone [the repository](https://github.com/bwrrp/slimdom.js) and run `npm install` to install its dependencies. + +The slimdom library and tests are developed in [TypeScript](https://www.typescriptlang.org/), using [prettier](https://github.com/prettier/prettier) to automate formatting. Settings for the vscode [vscode-prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) are included. If you use prettier from another editor, please use options equivalent to the command line `--print-width 120 --use-tabs --single-quote`. + +This repository includes a full suite of tests based on [mocha](http://mochajs.org/) and [chai](http://chaijs.com/), with coverage computed using [istanbul and nyc](https://istanbul.js.org/). Run `npm test` to run the tests, or `npm run test:debug` to debug the tests and code by disabling coverage and enabling the node inspector (see [chrome://inspect](chrome://inspect) in Chrome). + +An experimental runner for the W3C [web platform tests](http://web-platform-tests.org/) is included in the `test/web-platform-tests` directory. To use it, clone the [web platform tests repository](https://github.com/w3c/web-platform-tests) somewhere and set the `WEB_PLATFORM_TESTS_PATH` environment variable to the corresponding path. Then run `npm test` as normal. The `webPlatform.tests.ts` file contains a blacklist of tests that don't currently run due to missing features. diff --git a/bower.json b/bower.json deleted file mode 100644 index 28baf3b..0000000 --- a/bower.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "slimdom", - "version": "0.5.3", - "homepage": "https://github.com/bwrrp/slimdom.js", - "authors": [ - "Stef Busking " - ], - "description": "Fast, tiny DOM implementation in pure JS", - "main": "src/main.js", - "moduleType": [ - "amd", - "node" - ], - "keywords": [ - "dom", - "xml" - ], - "license": "MIT", - "ignore": [ - "**/.*", - "*.sublime-project", - "*.sublime-workspace", - "node_modules", - "bower_components", - "test" - ] -} diff --git a/package.json b/package.json index 88a6e05..51961a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "slimdom", - "version": "1.0.1", + "version": "2.0.0", "description": "Fast, tiny DOM implementation in pure JS", "author": "Stef Busking", "license": "MIT", @@ -11,13 +11,14 @@ "main": "dist/slimdom.js", "module": "dist/slimdom.mjs", "scripts": { - "build:amd": "rimraf lib && tsc --module amd", - "build:commonjs": "rimraf lib && tsc --module commonjs", - "build:es": "rimraf lib && tsc --module es6", - "build:bundle": "rimraf dist && rimraf lib && tsc && rollup -c", + "build:amd": "rimraf lib && tsc -P tsconfig.build.json --module amd", + "build:commonjs": "rimraf lib && tsc -P tsconfig.build.json --module commonjs", + "build:es": "rimraf lib && tsc -P tsconfig.build.json --module es6", + "build:bundle": "rimraf dist && rimraf lib && tsc -P tsconfig.build.json && rollup -c", "docs": "typedoc --out docs --excludePrivate --excludeNotExported src/index.ts", "prepare": "npm run build:bundle", - "test": "rimraf test/bin && tsc -P test && mocha --recursive test/bin/test" + "test": "rimraf lib && tsc -P tsconfig.json && nyc --reporter html --reporter text --exclude lib/test mocha --timeout 20000 --recursive lib/test", + "test:debug": "rimraf lib && tsc -P tsconfig.json && mocha --timeout 20000 --recursive lib/test --inspect --debug-brk" }, "files": [ "dist" @@ -33,6 +34,9 @@ "chai": "^3.5.0", "lolex": "^1.6.0", "mocha": "^3.3.0", + "nyc": "^11.0.2", + "parse5": "^3.0.2", + "prettier": "^1.4.4", "rimraf": "^2.6.1", "rollup": "^0.41.6", "rollup-plugin-babili": "^3.0.0", diff --git a/rollup.config.js b/rollup.config.js index 843d2f8..695983c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -3,18 +3,42 @@ import babili from 'rollup-plugin-babili'; const { main: MAIN_DEST_FILE, module: MODULE_DEST_FILE } = require('./package.json'); export default { - entry: 'lib/index.js', - targets: [ - { dest: MAIN_DEST_FILE, format: 'umd' }, - { dest: MODULE_DEST_FILE, format: 'es' }, - ], - moduleName: 'slimdom', - exports: 'default', - sourceMap: true, - plugins: [ - babili({ - comments: false, - sourceMap: true - }) - ] -} + entry: 'lib/index.js', + targets: [{ dest: MAIN_DEST_FILE, format: 'umd' }, { dest: MODULE_DEST_FILE, format: 'es' }], + moduleName: 'slimdom', + exports: 'named', + sourceMap: true, + onwarn(warning) { + // Ignore "this is undefined" warning triggered by typescript's __extends helper + if (warning.code === 'THIS_IS_UNDEFINED') { + return; + } + + console.error(warning.message); + }, + plugins: [ + babili({ + comments: false, + mangle: { + blacklist: [ + 'Attr', + 'CDATASection', + 'CharacterData', + 'Comment', + 'Document', + 'DocumentFragment', + 'DocumentType', + 'DOMImplementation', + 'Element', + 'Node', + 'MutationObserver', + 'ProcessingInstruction', + 'Range', + 'Text', + 'XMLDocument' + ] + }, + sourceMap: true + }) + ] +}; diff --git a/src/Attr.ts b/src/Attr.ts new file mode 100644 index 0000000..28820a5 --- /dev/null +++ b/src/Attr.ts @@ -0,0 +1,146 @@ +import Document from './Document'; +import Element from './Element'; +import Node from './Node'; +import { getContext } from './context/Context'; +import { changeAttribute } from './util/attrMutations'; +import { expectArity } from './util/errorHelpers'; +import { NodeType } from './util/NodeType'; +import { treatNullAsEmptyString } from './util/typeHelpers'; + +/** + * 3.9.2. Interface Attr + */ +export default class Attr extends Node { + // Node + + public get nodeType(): number { + return NodeType.ATTRIBUTE_NODE; + } + + public get nodeName(): string { + // Return the qualified name + return this.name; + } + + public get nodeValue(): string | null { + return this._value; + } + + public set nodeValue(newValue: string | null) { + newValue = treatNullAsEmptyString(newValue); + + // Set an existing attribute value with context object and new value. + setExistingAttributeValue(this, newValue); + } + + public lookupPrefix(namespace: string | null): string | null { + expectArity(arguments, 1); + + // 1. If namespace is null or the empty string, then return null. + // (not necessary due to recursion) + + // 2. Switch on the context object: + // Attr - Return the result of locating a namespace prefix for its element, if its element is non-null, and null + // otherwise. + if (this.ownerElement !== null) { + return this.ownerElement.lookupPrefix(namespace); + } + + return null; + } + + public lookupNamespaceURI(prefix: string | null): string | null { + expectArity(arguments, 1); + + // 1. If prefix is the empty string, then set it to null. + // (not necessary due to recursion) + + // 2. Return the result of running locate a namespace for the context object using prefix. + + // To locate a namespace for a node using prefix, switch on node: Attr + // 1. If its element is null, then return null. + if (this.ownerElement === null) { + return null; + } + + // 2. Return the result of running locate a namespace on its element using prefix. + return this.ownerElement.lookupNamespaceURI(prefix); + } + + // Attr + + public readonly namespaceURI: string | null; + public readonly prefix: string | null; + public readonly localName: string; + public readonly name: string; + + private _value: string; + + public get value(): string { + return this._value; + } + + public set value(value: string) { + setExistingAttributeValue(this, value); + } + + public ownerElement: Element | null; + + /** + * (non-standard) use Document#createAttribute(NS) or Element#setAttribute(NS) to create attribute nodes + * + * @param namespace The namespace URI for the attribute + * @param prefix The prefix for the attribute + * @param localName The local name for the attribute + * @param value The value for the attribute + * @param element The element for the attribute, or null if the attribute is not attached to an element + */ + constructor( + namespace: string | null, + prefix: string | null, + localName: string, + value: string, + element: Element | null + ) { + super(); + + this.namespaceURI = namespace; + this.prefix = prefix; + this.localName = localName; + this.name = prefix === null ? localName : `${prefix}:${localName}`; + this._value = value; + this.ownerElement = element; + } + + /** + * (non-standard) Creates a copy of the context object, not including its children. + * + * @param document The node document to associate with the copy + * + * @return A shallow copy of the context object + */ + public _copy(document: Document): Attr { + // Set copy’s namespace, namespace prefix, local name, and value, to those of node. + const context = getContext(document); + const copy = new context.Attr(this.namespaceURI, this.prefix, this.localName, this.value, null); + copy.ownerDocument = document; + return copy; + } +} + +/** + * To set an existing attribute value, given an attribute attribute and string value, run these steps: + * + * @param attribute The attribute to set the value of + * @param value The new value for attribute + */ +function setExistingAttributeValue(attribute: Attr, value: string) { + // 1. If attribute’s element is null, then set attribute’s value to value. + const element = attribute.ownerElement; + if (element === null) { + (attribute as any)._value = value; + } else { + // 2. Otherwise, change attribute from attribute’s element to value. + changeAttribute(attribute, element, value); + } +} diff --git a/src/CDATASection.ts b/src/CDATASection.ts new file mode 100644 index 0000000..1820d70 --- /dev/null +++ b/src/CDATASection.ts @@ -0,0 +1,42 @@ +import Document from './Document'; +import Text from './Text'; +import { getContext } from './context/Context'; +import { NodeType } from './util/NodeType'; + +export default class CDATASection extends Text { + // Node + + public get nodeType(): number { + return NodeType.CDATA_SECTION_NODE; + } + + public get nodeName(): string { + return '#cdata-section'; + } + + // CDATASection + + /** + * (non-standard) use Document#createCDATASection to create a CDATA section. + * + * @param data The data for the node + */ + constructor(data: string) { + super(data); + } + + /** + * (non-standard) Creates a copy of the context object, not including its children. + * + * @param document The node document to associate with the copy + * + * @return A shallow copy of the context object + */ + public _copy(document: Document): CDATASection { + // Set copy’s data, to that of node. + const context = getContext(document); + const copy = new context.CDATASection(this.data); + copy.ownerDocument = document; + return copy; + } +} diff --git a/src/CharacterData.ts b/src/CharacterData.ts index 092819f..82e80c9 100644 --- a/src/CharacterData.ts +++ b/src/CharacterData.ts @@ -1,143 +1,260 @@ +import { NonDocumentTypeChildNode, ChildNode, getNextElementSibling, getPreviousElementSibling } from './mixins'; import Document from './Document'; +import Element from './Element'; import Node from './Node'; - -import MutationRecord from './mutations/MutationRecord'; -import queueMutationRecord from './mutations/queueMutationRecord'; +import { ranges } from './Range'; +import queueMutationRecord from './mutation-observer/queueMutationRecord'; +import { expectArity, throwIndexSizeError } from './util/errorHelpers'; +import { asNullableString, asUnsignedLong, treatNullAsEmptyString } from './util/typeHelpers'; /** - * The CharacterData abstract interface represents a Node object that contains characters. This is an abstract - * interface, meaning there aren't any object of type CharacterData: it is implemented by other interfaces, - * like Text, Comment, or ProcessingInstruction which aren't abstract. + * 3.10. Interface CharacterData */ -export default abstract class CharacterData extends Node { - private _data: string; +export default abstract class CharacterData extends Node implements NonDocumentTypeChildNode, ChildNode { + // Node - public get data (): string { + public get nodeValue(): string | null { return this._data; } - public set data (newValue: string) { - this.replaceData(0, this._data.length, newValue); + public set nodeValue(newValue: string | null) { + newValue = treatNullAsEmptyString(newValue); + + // Set an existing attribute value with context object and new value. + replaceData(this, 0, this.length, newValue); + } + + public lookupPrefix(namespace: string | null): string | null { + expectArity(arguments, 1); + + // 1. If namespace is null or the empty string, then return null. + // (not necessary due to recursion) + + // 2. Switch on the context object: + // Any other node - Return the result of locating a namespace prefix for its parent element, if its parent + // element is non-null, and null otherwise. + const parentElement = this.parentElement; + if (parentElement !== null) { + return parentElement.lookupPrefix(namespace); + } + + return null; + } + + public lookupNamespaceURI(prefix: string | null): string | null { + expectArity(arguments, 1); + + // 1. If prefix is the empty string, then set it to null. + // (not necessary due to recursion) + + // 2. Return the result of running locate a namespace for the context object using prefix. + + // To locate a namespace for a node using prefix, switch on node: Any other node + // 1. If its parent element is null, then return null. + const parentElement = this.parentElement; + if (parentElement === null) { + return null; + } + + // 2. Return the result of running locate a namespace on its parent element using prefix. + return parentElement.lookupNamespaceURI(prefix); + } + + // NonDocumentTypeChildNode + + public get previousElementSibling(): Element | null { + return getPreviousElementSibling(this); + } + + public get nextElementSibling(): Element | null { + return getNextElementSibling(this); } + // CharacterData + /** - * Alias for data. + * Each node inheriting from the CharacterData interface has an associated mutable string called data. */ - public get nodeValue (): string { + protected _data: string; + + public get data(): string { return this._data; } - /** - * The length of the string used as textual data for this CharacterData node. - */ - public get length (): number { - return this._data.length; + public set data(newValue: string) { + // [TreatNullAs=EmptyString] + newValue = treatNullAsEmptyString(newValue); + + // replace data with node context object, offset 0, count context object’s length, and data new value. + replaceData(this, 0, this.length, newValue); + } + + public get length(): number { + return this.data.length; } /** - * @param type Node type - * @param data Content of the node + * (non-standard) CharacterData should never be instantiated directly. + * + * @param data The data to associate with the node */ - constructor (type: number, data: string) { - super(type); - - this._data = data; + protected constructor(data: string) { + super(); + this._data = String(data); } /** - * Returns a string containing the part of CharacterData.data of the specified length and starting at the - * specified offset. + * Returns a substring of the node's data. * * @param offset Offset at which to start the substring - * @param count Number of characters to return. If omitted, returns all data starting at offset. + * @param count The number of code units to return * - * @return The specified substring of the current content + * @return The specified substring */ - public substringData (offset: number, count?: number): string { - return this._data.substr(offset, count); + public substringData(offset: number, count: number): string { + expectArity(arguments, 2); + return substringData(this, offset, count); } /** - * Appends the given string to the CharacterData.data string; when this method returns, data contains the - * concatenated string. + * Appends data to the node's data. * - * @param data Content to add to the end of the current content + * @param data Data to append */ - public appendData (data: string) { - this.replaceData(this.length, 0, data); + public appendData(data: string): void { + expectArity(arguments, 1); + replaceData(this, this.length, 0, data); } /** - * Inserts the specified characters, at the specified offset, in the CharacterData.data string; when this method - * returns, data contains the modified string. + * Inserts data at the specified position in the node's data. * - * @param offset Offset at which to insert data - * @param data Content to insert + * @param offset Offset at which to insert + * @param data Data to insert */ - public insertData (offset: number, data: string) { - this.replaceData(offset, 0, data); + public insertData(offset: number, data: string): void { + expectArity(arguments, 1); + replaceData(this, offset, 0, data); } /** - * Removes the specified amount of characters, starting at the specified offset, from the CharacterData.data - * string; when this method returns, data contains the shortened string. + * Deletes data from the specified position. * - * @param offset Offset at which to start removing content - * @param count Number of characters to remove. Omitting count deletes from offset to the end of data. + * @param offset Offset at which to delete + * @param count Number of code units to delete */ - public deleteData (offset: number, count: number = this.length) { - this.replaceData(offset, count, ''); + public deleteData(offset: number, count: number): void { + expectArity(arguments, 2); + replaceData(this, offset, count, ''); } /** - * Replaces the specified amount of characters, starting at the specified offset, with the specified string; - * when this method returns, data contains the modified string. + * Replaces data at the specified position. * - * @param offset Offset at which to remove and then insert content - * @param count Number of characters to remove - * @param data Content to insert + * @param offset Offset at which to replace + * @param count Number of code units to remove + * @param data Data to insert */ - public replaceData (offset: number, count: number, data: string) { - const length = this.length; - if (offset > length) { - offset = length; - } + public replaceData(offset: number, count: number, data: string): void { + expectArity(arguments, 3); + replaceData(this, offset, count, data); + } +} + +/** + * To replace data of node node with offset offset, count count, and data data, run these steps: + * + * @param node The node to replace data on + * @param offset The offset at which to start replacing + * @param count The number of code units to replace + * @param data The data to insert in place of the removed data + */ +export function replaceData(node: CharacterData, offset: number, count: number, data: string): void { + // Match spec data types + offset = asUnsignedLong(offset); + count = asUnsignedLong(count); + + // 1. Let length be node’s length. + const length = node.length; + + // 2. If offset is greater than length, then throw an IndexSizeError. + if (offset > length) { + throwIndexSizeError("can not replace data past the node's length"); + } + + // 3. If offset plus count is greater than length, then set count to length minus offset. + if (offset + count > length) { + count = length - offset; + } + + // 4. Queue a mutation record of "characterData" for node with oldValue node’s data. + queueMutationRecord('characterData', node, { + oldValue: node.data + }); - if (offset + count > length) { - count = length - offset; + // 5. Insert data into node’s data after offset code units. + // 6. Let delete offset be offset plus the number of code units in data. + // 7. Starting from delete offset code units, remove count code units from node’s data. + const nodeData = node.data; + const newData = nodeData.substring(0, offset) + data + nodeData.substring(offset + count); + (node as any)._data = newData; + + ranges.forEach(range => { + // 8. For each range whose start node is node and start offset is greater than offset but less than or equal to + // offset plus count, set its start offset to offset. + if (range.startContainer === node && range.startOffset > offset && range.startOffset <= offset + count) { + range.startOffset = offset; } - const before = this.substringData(0, offset); - const after = this.substringData(offset + count); - const newData = before + data + after; + // 9. For each range whose end node is node and end offset is greater than offset but less than or equal to + // offset plus count, set its end offset to offset. + if (range.endContainer === node && range.endOffset > offset && range.endOffset <= offset + count) { + range.endOffset = offset; + } - if (newData !== this._data) { - // Queue mutation record - var record = new MutationRecord('characterData', this); - record.oldValue = this._data; - queueMutationRecord(record); + // 10. For each range whose start node is node and start offset is greater than offset plus count, increase its + // start offset by the number of code units in data, then decrease it by count. + if (range.startContainer === node && range.startOffset > offset + count) { + range.startOffset = range.startOffset + data.length - count; + } - // Replace data - this._data = newData; + // 11. For each range whose end node is node and end offset is greater than offset plus count, increase its end + // offset by the number of code units in data, then decrease it by count. + if (range.endContainer === node && range.endOffset > offset + count) { + range.endOffset = range.endOffset + data.length - count; } + }); +} - // Update ranges - var document = this.ownerDocument as Document; - document._ranges.forEach(range => { - if (range.startContainer === this && range.startOffset > offset && range.startOffset <= offset + count) { - range.setStart(range.startContainer, offset); - } - if (range.endContainer === this && range.endOffset > offset && range.endOffset <= offset + count) { - range.setEnd(range.endContainer, offset); - } - const startOffset = range.startOffset; - const endOffset = range.endOffset; - if (range.startContainer === this && startOffset > offset + count) { - range.setStart(range.startContainer, startOffset - count + data.length); - } - if (range.endContainer === this && endOffset > offset + count) { - range.setEnd(range.endContainer, endOffset - count + data.length); - } - }); +/** + * To substring data with node node, offset offset, and count count, run these steps: + * + * @param node The node to get data from + * @param offset The offset at which to start the substring + * @param count The number of code units to include in the substring + * + * @return The requested substring + */ +export function substringData(node: CharacterData, offset: number, count: number): string { + // Match spec data types + offset = asUnsignedLong(offset); + count = asUnsignedLong(count); + + // 1. Let length be node’s length. + const length = node.length; + + // 2. If offset is greater than length, then throw an IndexSizeError. + if (offset > length) { + throwIndexSizeError("can not substring data past the node's length"); } + + // 3. If offset plus count is greater than length, return a string whose value is the code units from the offsetth + // code unit to the end of node’s data, and then return. + if (offset + count > length) { + return node.data.substring(offset); + } + + // 4. Return a string whose value is the code units from the offsetth code unit to the offset+countth code unit in + // node’s data. + return node.data.substring(offset, offset + count); } diff --git a/src/Comment.ts b/src/Comment.ts index 38f3a29..b23fac6 100644 --- a/src/Comment.ts +++ b/src/Comment.ts @@ -1,22 +1,45 @@ import CharacterData from './CharacterData'; -import Node from './Node'; +import Document from './Document'; +import { getContext } from './context/Context'; +import { NodeType } from './util/NodeType'; -/** - * The Comment interface represents textual notations within markup; although it is generally not visually - * shown, such comments are available to be read in the source view. Comments are represented in HTML and - * XML as content between '<!--' and '-->'. In XML, the character sequence '--' cannot be used within - * a comment. - */ export default class Comment extends CharacterData { - /** - * @param data Text of the comment + // Node + + public get nodeType(): number { + return NodeType.COMMENT_NODE; + } + + public get nodeName(): string { + return '#comment'; + } + + // Comment + + /** + * Returns a new Comment node whose data is data and node document is current global object’s associated Document. + * + * @param data The data for the new comment */ - constructor (data: string = '') { - super(Node.COMMENT_NODE, data); + constructor(data: string = '') { + super(data); + + const context = getContext(this); + this.ownerDocument = context.document; } - public cloneNode (deep: boolean = true, copy?: Comment): Comment { - copy = copy || new Comment(this.data); - return super.cloneNode(deep, copy) as Comment; + /** + * (non-standard) Creates a copy of the context object, not including its children. + * + * @param document The node document to associate with the copy + * + * @return A shallow copy of the context object + */ + public _copy(document: Document): Comment { + // Set copy’s data, to that of node. + const context = getContext(document); + const copy = new context.Comment(this.data); + copy.ownerDocument = document; + return copy; } } diff --git a/src/DOMImplementation.ts b/src/DOMImplementation.ts index bc54702..1319fbd 100644 --- a/src/DOMImplementation.ts +++ b/src/DOMImplementation.ts @@ -1,52 +1,161 @@ import Document from './Document'; import DocumentType from './DocumentType'; +import { createElement } from './Element'; +import XMLDocument from './XMLDocument'; +import { getContext } from './context/Context'; +import createElementNS from './util/createElementNS'; +import { expectArity } from './util/errorHelpers'; +import { validateQualifiedName } from './util/namespaceHelpers'; +import { asNullableObject, asNullableString, treatNullAsEmptyString } from './util/typeHelpers'; + +const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; -/** - * The DOMImplementation interface represents an object providing methods which are not dependent on any - * particular document. Such an object is returned by the Document.implementation property. - */ export default class DOMImplementation { + private _document: Document; + /** - * Returns a DocumentType object which can either be used with DOMImplementation.createDocument upon document - * creation or can be put into the document via methods like Node.insertBefore() or Node.replaceChild(). + * (non-standard) Use Document#implementation to access instances of this class * - * @param qualifiedName The name of the doctype - * @param publicId The public identifier of the doctype - * @param systemId The system identifier of the doctype - * - * @return The new doctype + * @param document The document to associate with this instance */ - public createDocumentType (qualifiedName: string, publicId: string, systemId: string): DocumentType { - return new DocumentType(qualifiedName, publicId, systemId); + constructor(document: Document) { + this._document = document; } /** - * Creates and returns a new Document. + * Returns a doctype, with the given qualifiedName, publicId, and systemId. + * + * @param qualifiedName Qualified name for the doctype + * @param publicId Public ID for the doctype + * @param systemId System ID for the doctype * - * Note that namespaces are not currently supported; namespace and any prefix in qualifiedName will be ignored + * @return The new doctype node + */ + createDocumentType(qualifiedName: string, publicId: string, systemId: string): DocumentType { + expectArity(arguments, 3); + qualifiedName = String(qualifiedName); + publicId = String(publicId); + systemId = String(systemId); + + // 1. Validate qualifiedName. + validateQualifiedName(qualifiedName); + + // 2. Return a new doctype, with qualifiedName as its name, publicId as its public ID, and systemId as its + // system ID, and with its node document set to the associated document of the context object. + const context = getContext(this._document); + const doctype = new context.DocumentType(qualifiedName, publicId, systemId); + doctype.ownerDocument = this._document; + return doctype; + } + + /** + * Returns an XMLDocument, with a document element whose local name is qualifiedName and whose namespace is + * namespace (unless qualifiedName is the empty string), and with doctype, if it is given, as its doctype. * - * @param namespace Namespace URI for the new document's root element, not currently supported - * @param qualifiedName Qualified name for the new document's root element, currently interpreted as local name - * @param doctype Document type for the new document, or null to omit + * @param namespace The namespace for the root element + * @param qualifiedName The qualified name for the root element, or empty string to not create a root element + * @param doctype The doctype for the new document, or null to not add a doctype * - * @return The new Document, with optional doctype and/or root element + * @return The new XMLDocument */ - public createDocument (namespace: string | null, qualifiedName: string, doctype: DocumentType | null = null) { - const document = new Document(); + createDocument( + namespace: string | null, + qualifiedName: string | null, + doctype: DocumentType | null = null + ): XMLDocument { + expectArity(arguments, 2); + namespace = asNullableString(namespace); + // [TreatNullAs=EmptyString] for qualifiedName + qualifiedName = treatNullAsEmptyString(qualifiedName); + doctype = asNullableObject(doctype, DocumentType); + + // 1. Let document be a new XMLDocument. + const context = getContext(this._document); + const document = new context.XMLDocument(); + + // 2. Let element be null. let element = null; + + // 3. If qualifiedName is not the empty string, then set element to the result of running the internal + // createElementNS steps, given document, namespace, qualifiedName, and an empty dictionary. if (qualifiedName !== '') { - // TODO: use createElementNS once it is supported - element = document.createElement(qualifiedName); + element = createElementNS(document, namespace, qualifiedName); } + // 4. If doctype is non-null, append doctype to document. if (doctype) { document.appendChild(doctype); } + // 5. If element is non-null, append element to document. if (element) { document.appendChild(element); } + // 6. document’s origin is context object’s associated document’s origin. + // (origin not implemented) + + // 7. document’s content type is determined by namespace: + // HTML namespace: application/xhtml+xml + // SVG namespace: image/svg+xml + // Any other namespace: application/xml + // (content type not implemented) + + // 8. Return document. return document; } + + /** + * Returns a HTML document with a basic tree already constructed. + * + * @param title Optional title for the new HTML document + * + * @return The new document + */ + createHTMLDocument(title?: string | null): Document { + title = asNullableString(title); + + // 1. Let doc be a new document that is an HTML document. + const context = getContext(this._document); + const doc = new context.Document(); + + // 2. Set doc’s content type to "text/html". + // (content type not implemented) + + // 3. Append a new doctype, with "html" as its name and with its node document set to doc, to doc. + const doctype = new context.DocumentType('html'); + doctype.ownerDocument = doc; + doc.appendChild(doctype); + + // 4. Append the result of creating an element given doc, html, and the HTML namespace, to doc. + const htmlElement = createElement(doc, 'html', HTML_NAMESPACE); + doc.appendChild(htmlElement); + + // 5. Append the result of creating an element given doc, head, and the HTML namespace, to the html element + // created earlier. + const headElement = createElement(doc, 'head', HTML_NAMESPACE); + htmlElement.appendChild(headElement); + + // 6. If title is given: + if (title !== null) { + // 6.1. Append the result of creating an element given doc, title, and the HTML namespace, to the head + // element created earlier. + const titleElement = createElement(doc, 'title', HTML_NAMESPACE); + headElement.appendChild(titleElement); + + // 6.2. Append a new Text node, with its data set to title (which could be the empty string) and its node + // document set to doc, to the title element created earlier. + titleElement.appendChild(doc.createTextNode(title)); + } + + // 7. Append the result of creating an element given doc, body, and the HTML namespace, to the html element + // created earlier. + htmlElement.appendChild(createElement(doc, 'body', HTML_NAMESPACE)); + + // 8. doc’s origin is context object’s associated document’s origin. + // (origin not implemented) + + // 9. Return doc. + return doc; + } } diff --git a/src/Document.ts b/src/Document.ts index 45ede35..9e9b73c 100644 --- a/src/Document.ts +++ b/src/Document.ts @@ -1,147 +1,405 @@ +import { NonElementParentNode, ParentNode, getChildren } from './mixins'; +import Attr from './Attr'; +import CDATASection from './CDATASection'; import Comment from './Comment'; +import DocumentFragment from './DocumentFragment'; import DocumentType from './DocumentType'; import DOMImplementation from './DOMImplementation'; -import Element from './Element'; +import { createElement, default as Element } from './Element'; import Node from './Node'; import ProcessingInstruction from './ProcessingInstruction'; import Text from './Text'; +import Range from './Range'; +import { getContext } from './context/Context'; +import cloneNode from './util/cloneNode'; +import createElementNS from './util/createElementNS'; +import { expectArity, throwInvalidCharacterError, throwNotSupportedError } from './util/errorHelpers'; +import { adoptNode } from './util/mutationAlgorithms'; +import { NodeType, isNodeOfType } from './util/NodeType'; +import { matchesNameProduction, validateAndExtract } from './util/namespaceHelpers'; +import { asNullableString, asObject } from './util/typeHelpers'; -import Range from './selections/Range'; +/** + * 3.5. Interface Document + */ +export default class Document extends Node implements NonElementParentNode, ParentNode { + // Node -import { implementation } from './globals'; + public get nodeType(): number { + return NodeType.DOCUMENT_NODE; + } + + public get nodeName(): string { + return '#document'; + } + + public get nodeValue(): string | null { + return null; + } + + public set nodeValue(newValue: string | null) { + // Do nothing. + } + + public lookupPrefix(namespace: string | null): string | null { + expectArity(arguments, 1); + + // 1. If namespace is null or the empty string, then return null. + // (not necessary due to recursion) + + // 2. Switch on the context object: + // Document - Return the result of locating a namespace prefix for its document element, if its document element + // is non-null, and null otherwise. + if (this.documentElement !== null) { + return this.documentElement.lookupPrefix(namespace); + } + + return null; + } + + public lookupNamespaceURI(prefix: string | null): string | null { + expectArity(arguments, 1); + + // 1. If prefix is the empty string, then set it to null. + // (not necessary due to recursion) + + // 2. Return the result of running locate a namespace for the context object using prefix. + + // To locate a namespace for a node using prefix, switch on node: Document + // 1. If its document element is null, then return null. + if (this.documentElement === null) { + return null; + } + + // 2. Return the result of running locate a namespace on its document element using prefix. + return this.documentElement.lookupNamespaceURI(prefix); + } + + // ParentNode + + public get children(): Element[] { + return getChildren(this); + } + + public firstElementChild: Element | null = null; + public lastElementChild: Element | null = null; + public childElementCount: number = 0; + + // Document -export default class Document extends Node { /** - * The DocumentType that is a direct child of the current document, or null if there is none. + * Returns a reference to the DOMImplementation object associated with the document. + */ + public readonly implementation: DOMImplementation = new DOMImplementation(this); + + /** + * The doctype, or null if there is none. */ public doctype: DocumentType | null = null; /** - * The Element that is a direct child of the current document, or null if there is none. + * The document element, or null if there is none. */ public documentElement: Element | null = null; /** - * Returns a reference to the DOMImplementation object which created the document. + * Creates a new Document. + * + * Note: Unlike DOMImplementation#createDocument(), this constructor does not return an XMLDocument object, but a + * document (Document object). */ - public implementation: DOMImplementation = implementation; + constructor() { + super(); + } /** - * (internal) The ranges that are active on the current document. + * Creates a new element in the null namespace. + * + * @param localName Local name of the element + * + * @return The new element */ - public _ranges: Range[] = []; + public createElement(localName: string): Element { + expectArity(arguments, 1); + localName = String(localName); + + // 1. If localName does not match the Name production, then throw an InvalidCharacterError. + if (!matchesNameProduction(localName)) { + throwInvalidCharacterError('The local name is not a valid Name'); + } - constructor () { - super(Node.DOCUMENT_NODE); + // 2. If the context object is an HTML document, then set localName to localName in ASCII lowercase. + // (html documents not implemented) - // Non-standard: should be null for Document nodes. - this.ownerDocument = this; + // 3. Let is be the value of is member of options, or null if no such member exists. + // (custom elements not implemented) + + // 4. Let namespace be the HTML namespace, if the context object is an HTML document or context object’s content + // type is "application/xhtml+xml", and null otherwise. + // (html documents not implemented) + const namespace: string | null = null; + + // 5. Let element be the result of creating an element given the context object, localName, namespace, null, is, + // and with the synchronous custom elements flag set. + const element = createElement(this, localName, namespace, null); + + // 6. If is is non-null, then set an attribute value for element using "is" and is. + // (custom elements not implemented) + + // 7. Return element. + return element; } - // Override insertBefore to update the documentElement reference. - public insertBefore (newNode: Node, referenceNode: Node | null, suppressObservers: boolean = false): Node | null { - // Document can not have more than one child element node - if (newNode.nodeType === Node.ELEMENT_NODE && this.documentElement) { - return this.documentElement === newNode ? newNode : null; - } + /** + * Creates a new element in the given namespace. + * + * @param namespace Namespace URI for the new element + * @param qualifiedName Qualified name for the new element + * + * @return The new element + */ + public createElementNS(namespace: string | null, qualifiedName: string): Element { + expectArity(arguments, 2); + namespace = asNullableString(namespace); + qualifiedName = String(qualifiedName); - // Document can not have more than one child doctype node - if (newNode.nodeType === Node.DOCUMENT_TYPE_NODE && this.doctype) { - return this.doctype === newNode ? newNode : null; - } + // return the result of running the internal createElementNS steps, given context object, namespace, + // qualifiedName, and options. + return createElementNS(this, namespace, qualifiedName); + } - const result = super.insertBefore(newNode, referenceNode, suppressObservers); + /** + * Returns a new DocumentFragment node with its node document set to the context object. + * + * @return The new document fragment + */ + public createDocumentFragment(): DocumentFragment { + const context = getContext(this); + const documentFragment = new context.DocumentFragment(); + documentFragment.ownerDocument = this; + return documentFragment; + } - // Update document element - if (result && result.nodeType === Node.ELEMENT_NODE) { - this.documentElement = result as Element; - } + /** + * Returns a new Text node with its data set to data and node document set to the context object. + * + * @param data Data for the new text node + * + * @return The new text node + */ + public createTextNode(data: string): Text { + expectArity(arguments, 1); + data = String(data); + + const context = getContext(this); + const text = new context.Text(data); + text.ownerDocument = this; + return text; + } - // Update doctype - if (result && result.nodeType === Node.DOCUMENT_TYPE_NODE) { - this.doctype = result as DocumentType; + /** + * Returns a new CDATA section with the given data and node document set to the context object. + * + * @param data Data for the new CDATA section + * + * @return The new CDATA section + */ + public createCDATASection(data: string): CDATASection { + expectArity(arguments, 1); + data = String(data); + + // 1. If context object is an HTML document, then throw a NotSupportedError. + // (html documents not implemented) + + // 2. If data contains the string "]]>", then throw an InvalidCharacterError. + if (data.indexOf(']]>') >= 0) { + throwInvalidCharacterError('Data must not contain the string "]]>"'); } - return result; + // 3. Return a new CDATASection node with its data set to data and node document set to the context object. + const context = getContext(this); + const cdataSection = new context.CDATASection(data); + cdataSection.ownerDocument = this; + return cdataSection; + } + + /** + * Returns a new Comment node with its data set to data and node document set to the context object. + * + * @param data Data for the new comment + * + * @return The new comment node + */ + public createComment(data: string): Comment { + expectArity(arguments, 1); + data = String(data); + + const context = getContext(this); + const comment = new context.Comment(data); + comment.ownerDocument = this; + return comment; } - // Override removeChild to keep the documentElement property in sync. - public removeChild (childNode: Node, suppressObservers: boolean = false): Node | null { - var result = Node.prototype.removeChild.call(this, childNode, suppressObservers); - if (result === this.documentElement) { - this.documentElement = null; + /** + * Creates a new processing instruction node, with target set to target, data set to data, and node document set to + * the context object. + * + * @param target Target for the new processing instruction + * @param data Data for the new processing instruction + * + * @return The new processing instruction + */ + public createProcessingInstruction(target: string, data: string): ProcessingInstruction { + expectArity(arguments, 2); + target = String(target); + data = String(data); + + // 1. If target does not match the Name production, then throw an InvalidCharacterError. + if (!matchesNameProduction(target)) { + throwInvalidCharacterError('The target is not a valid Name'); } - else if (result === this.doctype) { - this.doctype = null; + + // 2. If data contains the string "?>", then throw an InvalidCharacterError. + if (data.indexOf('?>') >= 0) { + throwInvalidCharacterError('Data must not contain the string "?>"'); } - return result; + // 3. Return a new ProcessingInstruction node, with target set to target, data set to data, and node document + // set to the context object. + const context = getContext(this); + const pi = new context.ProcessingInstruction(target, data); + pi.ownerDocument = this; + return pi; + + // Note: No check is performed that target contains "xml" or ":", or that data contains characters that match + // the Char production. } /** - * Creates a new Element node with the given tag name. + * Creates a copy of a node from an external document that can be inserted into the current document. * - * @param name NodeName of the new Element + * @param node The node to import + * @param deep Whether to also import node's children + */ + public importNode(node: Node, deep: boolean = false): Node { + expectArity(arguments, 1); + node = asObject(node, Node); + + // 1. If node is a document or shadow root, then throw a NotSupportedError. + if (isNodeOfType(node, NodeType.DOCUMENT_NODE)) { + throwNotSupportedError('importing a Document node is not supported'); + } + + // 2. Return a clone of node, with context object and the clone children flag set if deep is true. + return cloneNode(node, deep, this); + } + + /** + * Adopts a node. The node and its subtree is removed from the document it's in (if any), and its ownerDocument is + * changed to the current document. The node can then be inserted into the current document. * - * @return The new Element + * @param node The node to adopt */ - public createElement (name: string): Element { - const node = new Element(name); - node.ownerDocument = this; + public adoptNode(node: Node): Node { + expectArity(arguments, 1); + node = asObject(node, Node); + + // 1. If node is a document, then throw a NotSupportedError. + if (isNodeOfType(node, NodeType.DOCUMENT_NODE)) { + throwNotSupportedError('adopting a Document node is not supported'); + } + + // 2. If node is a shadow root, then throw a HierarchyRequestError. + // (shadow dom not implemented) + + // 3. Adopt node into the context object. + adoptNode(node, this); + + // 4. Return node. return node; } /** - * Creates a new Text node with the given content. + * Creates a new attribute node with the null namespace and given local name. * - * @param content Content for the new text node + * @param localName The local name of the attribute * - * @return The new text node + * @return The new attribute node */ - public createTextNode (content: string): Text { - const node = new Text(content); - node.ownerDocument = this; - return node; + public createAttribute(localName: string): Attr { + expectArity(arguments, 1); + localName = String(localName); + + // 1. If localName does not match the Name production in XML, then throw an InvalidCharacterError. + if (!matchesNameProduction(localName)) { + throwInvalidCharacterError('The local name is not a valid Name'); + } + + // 2. If the context object is an HTML document, then set localName to localName in ASCII lowercase. + // (html documents not implemented) + + // 3. Return a new attribute whose local name is localName and node document is context object. + const context = getContext(this); + const attr = new context.Attr(null, null, localName, '', null); + attr.ownerDocument = this; + return attr; } /** - * Creates a new ProcessingInstruction node with a given target and given data. + * Creates a new attribute node with the given namespace and qualified name. * - * @param target Target of the processing instruction - * @param data Content of the processing instruction + * @param namespace Namespace URI for the new attribute, or null for the null namespace + * @param qualifiedName Qualified name for the new attribute * - * @return The new processing instruction + * @return The new attribute node */ - public createProcessingInstruction (target: string, data: string): ProcessingInstruction { - const node = new ProcessingInstruction(target, data); - node.ownerDocument = this; - return node; + public createAttributeNS(namespace: string | null, qualifiedName: string): Attr { + expectArity(arguments, 2); + namespace = asNullableString(namespace); + qualifiedName = String(qualifiedName); + + // 1. Let namespace, prefix, and localName be the result of passing namespace and qualifiedName to validate and + // extract. + const { namespace: validatedNamespace, prefix, localName } = validateAndExtract(namespace, qualifiedName); + + // 2. Return a new attribute whose namespace is namespace, namespace prefix is prefix, local name is localName, + // and node document is context object. + const context = getContext(this); + const attr = new context.Attr(validatedNamespace, prefix, localName, '', null); + attr.ownerDocument = this; + return attr; } /** - * Creates a new Comment node with the given data. + * Creates a new Range, initially positioned at the root of this document. * - * @param data Content of the comment + * Note: although the spec encourages use of the Range() constructor, this implementation does not associate any + * Document with the global object, preventing implementation of that constructor. * - * @return The new comment node + * @return The new Range */ - public createComment (data: string): Comment { - const node = new Comment(data); - node.ownerDocument = this; - return node; + public createRange(): Range { + const context = getContext(this); + const range = new context.Range(); + range.startContainer = this; + range.startOffset = 0; + range.endContainer = this; + range.endOffset = 0; + return range; } /** - * Creates a selection range within the current document. + * (non-standard) Creates a copy of the context object, not including its children. + * + * @param document The node document to associate with the copy * - * @return The new range, positioned just inside the root of the document + * @return A shallow copy of the context object */ - public createRange (): Range { - return new Range(this); - } + public _copy(document: Document): Document { + // Set copy’s encoding, content type, URL, origin, type, and mode, to those of node. + // (properties not implemented) - public cloneNode (deep: boolean = true, copy?: Document): Document { - copy = copy || new Document(); - return super.cloneNode(deep, copy) as Document; + const context = getContext(document); + return new context.Document(); } } diff --git a/src/DocumentFragment.ts b/src/DocumentFragment.ts new file mode 100644 index 0000000..f8b383f --- /dev/null +++ b/src/DocumentFragment.ts @@ -0,0 +1,85 @@ +import { NonElementParentNode, ParentNode, getChildren } from './mixins'; +import Document from './Document'; +import Element from './Element'; +import Node from './Node'; +import { getContext } from './context/Context'; +import { expectArity } from './util/errorHelpers'; +import { NodeType } from './util/NodeType'; + +export default class DocumentFragment extends Node implements NonElementParentNode, ParentNode { + // Node + + public get nodeType(): number { + return NodeType.DOCUMENT_FRAGMENT_NODE; + } + + public get nodeName(): string { + return '#document-fragment'; + } + + public get nodeValue(): string | null { + return null; + } + + public set nodeValue(newValue: string | null) { + // Do nothing. + } + + public lookupPrefix(namespace: string | null): string | null { + expectArity(arguments, 1); + + // 1. If namespace is null or the empty string, then return null. + // (not necessary due to return value) + + // 2. Switch on the context object: + // DocumentFragment - Return null + return null; + } + + public lookupNamespaceURI(prefix: string | null): string | null { + expectArity(arguments, 1); + + // 1. If prefix is the empty string, then set it to null. + // (not necessary due to return value) + + // 2. Return the result of running locate a namespace for the context object using prefix. + + // To locate a namespace for a node using prefix, switch on node: DocumentFragment + // Return null. + return null; + } + + // ParentNode + + public get children(): Element[] { + return getChildren(this); + } + + public firstElementChild: Element | null = null; + public lastElementChild: Element | null = null; + public childElementCount: number = 0; + + /** + * Return a new DocumentFragment node whose node document is current global object’s associated Document. + */ + constructor() { + super(); + + const context = getContext(this); + this.ownerDocument = context.document; + } + + /** + * (non-standard) Creates a copy of the context object, not including its children. + * + * @param document The node document to associate with the copy + * + * @return A shallow copy of the context object + */ + public _copy(document: Document): DocumentFragment { + const context = getContext(document); + const copy = new context.DocumentFragment(); + copy.ownerDocument = document; + return copy; + } +} diff --git a/src/DocumentType.ts b/src/DocumentType.ts index 8e313bc..7ad624d 100644 --- a/src/DocumentType.ts +++ b/src/DocumentType.ts @@ -1,28 +1,97 @@ +import { ChildNode } from './mixins'; +import Document from './Document'; import Node from './Node'; +import { getContext } from './context/Context'; +import { expectArity } from './util/errorHelpers'; +import { NodeType } from './util/NodeType'; -/** - * The DocumentType interface represents a Node containing a doctype. - */ -export default class DocumentType extends Node { +export default class DocumentType extends Node implements ChildNode { + // Node + + public get nodeType(): number { + return NodeType.DOCUMENT_TYPE_NODE; + } + + public get nodeName(): string { + return this.name; + } + + public get nodeValue(): string | null { + return null; + } + + public set nodeValue(newValue: string | null) { + // Do nothing. + } + + public lookupPrefix(namespace: string | null): string | null { + expectArity(arguments, 1); + + // 1. If namespace is null or the empty string, then return null. + // (not necessary due to return value) + + // 2. Switch on the context object: + // DocumentType - Return null + return null; + } + + public lookupNamespaceURI(prefix: string | null): string | null { + expectArity(arguments, 1); + + // 1. If prefix is the empty string, then set it to null. + // (not necessary due to return value) + + // 2. Return the result of running locate a namespace for the context object using prefix. + + // To locate a namespace for a node using prefix, switch on node: DocumentType + // Return null. + return null; + } + + // DocumentType + + /** + * The name of the doctype. + */ public name: string; + + /** + * The public ID of the doctype. + */ public publicId: string; + + /** + * The system ID of the doctype. + */ public systemId: string; /** - * @param name The name of the document type - * @param publicId The public identifier of the doctype - * @param systemId The system identifier of the doctype + * (non-standard) Use DOMImplementation#createDocumentType instead. + * + * @param name The name of the doctype + * @param publicId The public ID of the doctype + * @param systemId The system ID of the doctype */ - constructor (name: string, publicId: string, systemId: string) { - super(Node.DOCUMENT_TYPE_NODE); + constructor(name: string, publicId: string = '', systemId: string = '') { + super(); this.name = name; this.publicId = publicId; this.systemId = systemId; } - public cloneNode (deep: boolean = true, copy?: DocumentType): DocumentType { - copy = copy || new DocumentType(this.name, this.publicId, this.systemId); - return super.cloneNode(deep, copy) as DocumentType; + /** + * (non-standard) Creates a copy of the context object, not including its children. + * + * @param document The node document to associate with the copy + * + * @return A shallow copy of the context object + */ + public _copy(document: Document): DocumentType { + // Set copy’s name, public ID, and system ID, to those of node. + const context = getContext(document); + const copy = new context.DocumentType(this.name, this.publicId, this.systemId); + copy.ownerDocument = document; + return copy; } } diff --git a/src/Element.ts b/src/Element.ts index a11cdd7..0f3e4f2 100644 --- a/src/Element.ts +++ b/src/Element.ts @@ -1,250 +1,695 @@ +import { ParentNode, NonDocumentTypeChildNode, ChildNode } from './mixins'; +import { getChildren, getPreviousElementSibling, getNextElementSibling } from './mixins'; +import Attr from './Attr'; +import Document from './Document'; import Node from './Node'; - -import MutationRecord from './mutations/MutationRecord'; -import queueMutationRecord from './mutations/queueMutationRecord'; - -export interface Attr { - name: string; - value: string; -} +import { getContext } from './context/Context'; +import { appendAttribute, changeAttribute, removeAttribute, replaceAttribute } from './util/attrMutations'; +import { + expectArity, + throwInUseAttributeError, + throwInvalidCharacterError, + throwNotFoundError +} from './util/errorHelpers'; +import { + matchesNameProduction, + validateAndExtract, + locateNamespacePrefix, + XMLNS_NAMESPACE +} from './util/namespaceHelpers'; +import { NodeType } from './util/NodeType'; +import { asNullableString, asObject } from './util/typeHelpers'; /** - * Internal helper used to check if the given node is an Element object. - * - * @param node Node to check - * - * @return Whether node is an element + * 3.9. Interface Element */ -function isElement (node?: Node | null): boolean { - return !!node && node.nodeType === Node.ELEMENT_NODE; -} +export default class Element extends Node implements ParentNode, NonDocumentTypeChildNode, ChildNode { + // Node -/** - * Returns the first element sibling in the given direction: if it's backwards it's the first previousSibling - * node starting from the given node that's an Element, if it's forwards it's the first nextSibling node that's - * an Element. - * - * @param node Node to start from - * @param backwards Whether to look at node's preceding rather than its following siblings - * - * @return The element if found, or null otherwise - */ -function findNextElementSibling (node: Node | null, backwards: boolean): Element | null { - while (node) { - node = backwards ? node.previousSibling : node.nextSibling; - if (isElement(node)) { - break; + public get nodeType(): number { + return NodeType.ELEMENT_NODE; + } + + public get nodeName(): string { + return this.tagName; + } + + public get nodeValue(): string | null { + return null; + } + + public set nodeValue(newValue: string | null) { + // Do nothing. + } + + public lookupPrefix(namespace: string | null): string | null { + expectArity(arguments, 1); + namespace = asNullableString(namespace); + + // 1. If namespace is null or the empty string, then return null. + if (namespace === null || namespace === '') { + return null; } + + // 2. Switch on the context object: + // Element - Return the result of locating a namespace prefix for it using namespace. + return locateNamespacePrefix(this, namespace); } - return node as Element; -} + public lookupNamespaceURI(prefix: string | null): string | null { + expectArity(arguments, 1); + prefix = asNullableString(prefix); -/** - * The Element interface represents part of the document. This interface describes methods and properties common - * to each kind of elements. Specific behaviors are described in the specific interfaces, inheriting from - * Element: the HTMLElement interface for HTML elements, or the SVGElement interface for SVG elements. - */ -export default class Element extends Node { - /** - * The name of the element. - */ - public nodeName: string; + // 1. If prefix is the empty string, then set it to null. + if (prefix === '') { + prefix = null; + } + + // 2. Return the result of running locate a namespace for the context object using prefix. + + // To locate a namespace for a node using prefix, switch on node: Element + // 1. If its namespace is not null and its namespace prefix is prefix, then return namespace. + if (this.namespaceURI !== null && this.prefix === prefix) { + return this.namespaceURI; + } + + // 2. If it has an attribute whose namespace is the XMLNS namespace, namespace prefix is "xmlns", and local name + // is prefix, or if prefix is null and it has an attribute whose namespace is the XMLNS namespace, namespace + // prefix is null, and local name is "xmlns", then return its value if it is not the empty string, and null + // otherwise. + let ns = null; + if (prefix !== null) { + const attr = this.getAttributeNodeNS(XMLNS_NAMESPACE, prefix); + if (attr && attr.prefix === 'xmlns') { + ns = attr.value; + } + } else { + const attr = this.getAttributeNodeNS(XMLNS_NAMESPACE, 'xmlns'); + if (attr && attr.prefix === null) { + ns = attr.value; + } + } + if (ns !== null) { + return ns !== '' ? ns : null; + } + + // 3. If its parent element is null, then return null. + const parentElement = this.parentElement; + if (parentElement === null) { + return null; + } + + // 4. Return the result of running locate a namespace on its parent element using prefix. + return parentElement.lookupNamespaceURI(prefix); + } + + // ParentNode + + public get children(): Element[] { + return getChildren(this); + } + + public firstElementChild: Element | null = null; + public lastElementChild: Element | null = null; + public childElementCount: number = 0; + + // NonDocumentTypeChildNode + + public get previousElementSibling(): Element | null { + return getPreviousElementSibling(this); + } + + public get nextElementSibling(): Element | null { + return getNextElementSibling(this); + } + + // Element + + public readonly namespaceURI: string | null; + public readonly prefix: string | null; + public readonly localName: string; + public readonly tagName: string; /** - * The attributes as an array of Attr objects, having name and value. + * (non-standard) Use Document#createElement or Document#createElementNS to create an Element. + * + * @param namespace Namespace for the element + * @param prefix Prefix for the element + * @param localName Local name for the element */ - public attributes: Attr[] = []; + constructor(namespace: string | null, prefix: string | null, localName: string) { + super(); + + this.namespaceURI = namespace; + this.prefix = prefix; + this.localName = localName; + this.tagName = prefix === null ? localName : `${prefix}:${localName}`; + } /** - * Internal lookup of Attr objects by their name. + * Returns whether the element has any attributes. + * + * @return True if the element has attributes, otherwise false */ - private _attrByName: { [key: string]: Attr } = {}; + public hasAttributes(): boolean { + return this.attributes.length > 0; + } /** - * The first child node of the current element that's an Element node. + * The attributes for the element. + * + * Non-standard: the spec defines this as a NamedNodeMap, while this implementation uses an array. */ - public firstElementChild: Element | null = null; + public readonly attributes: Attr[] = []; /** - * The last child node of the current element that's an Element node. + * Get the value of the specified attribute. + * + * @param qualifiedName The qualified name of the attribute + * + * @return The value of the attribute, or null if no such attribute exists */ - public lastElementChild: Element | null = null; + public getAttribute(qualifiedName: string): string | null { + expectArity(arguments, 1); + qualifiedName = String(qualifiedName); + + // 1. Let attr be the result of getting an attribute given qualifiedName and the context object. + const attr = getAttributeByName(qualifiedName, this); + + // 2. If attr is null, return null. + if (attr === null) { + return null; + } + + // 3. Return attr’s value. + return attr.value; + } /** - * The previous sibling node of the current element that's an Element node. + * Get the value of the specified attribute. + * + * @param namespace The namespace of the attribute + * @param localName The local name of the attribute + * + * @return The value of the attribute, or null if no such attribute exists */ - public previousElementSibling: Element | null = null; + public getAttributeNS(namespace: string | null, localName: string): string | null { + expectArity(arguments, 2); + namespace = asNullableString(namespace); + localName = String(localName); + + // 1. Let attr be the result of getting an attribute given namespace, localName, and the context object. + const attr = getAttributeByNamespaceAndLocalName(namespace, localName, this); + + // 2. If attr is null, return null. + if (attr === null) { + return null; + } + + // 3. Return attr’s value. + return attr.value; + } /** - * The next sibling node of the current element that's an Element node. + * Sets the value of the specified attribute. + * + * @param qualifiedName The qualified name of the attribute + * @param value The new value for the attribute */ - public nextElementSibling: Element | null = null; + public setAttribute(qualifiedName: string, value: string): void { + expectArity(arguments, 2); + qualifiedName = String(qualifiedName); + value = String(value); + + // 1. If qualifiedName does not match the Name production in XML, then throw an InvalidCharacterError. + if (!matchesNameProduction(qualifiedName)) { + throwInvalidCharacterError('The qualified name does not match the Name production'); + } + + // 2. If the context object is in the HTML namespace and its node document is an HTML document, then set + // qualifiedName to qualifiedName in ASCII lowercase. + // (html documents not implemented) + + // 3. Let attribute be the first attribute in context object’s attribute list whose qualified name is + // qualifiedName, and null otherwise. + const attribute = getAttributeByName(qualifiedName, this); + + // 4. If attribute is null, create an attribute whose local name is qualifiedName, value is value, and node + // document is context object’s node document, then append this attribute to context object, and then return. + if (attribute === null) { + const context = getContext(this); + const attribute = new context.Attr(null, null, qualifiedName, value, this); + attribute.ownerDocument = this.ownerDocument; + appendAttribute(attribute, this); + return; + } + + // 5. Change attribute from context object to value. + changeAttribute(attribute, this, value); + } /** - * The number of child nodes of the current element that are Element nodes. + * Sets the value of the specified attribute. + * + * @param namespace The namespace of the attribute + * @param qualifiedName The qualified name of the attribute + * @param value The value for the attribute */ - public childElementCount: number = 0; + public setAttributeNS(namespace: string | null, qualifiedName: string, value: string): void { + expectArity(arguments, 3); + namespace = asNullableString(namespace); + qualifiedName = String(qualifiedName); + value = String(value); + + // 1. Let namespace, prefix, and localName be the result of passing namespace and qualifiedName to validate and + // extract. + const { namespace: validatedNamespace, prefix, localName } = validateAndExtract(namespace, qualifiedName); + + // 2. Set an attribute value for the context object using localName, value, and also prefix and namespace. + setAttributeValue(this, localName, value, prefix, validatedNamespace); + } /** - * @param name The NodeName for the Element + * Removes the specified attribute. + * + * @param qualifiedName The qualified name of the attribute */ - constructor (name: string) { - super(Node.ELEMENT_NODE); + public removeAttribute(qualifiedName: string): void { + expectArity(arguments, 1); + qualifiedName = String(qualifiedName); - this.nodeName = name; + removeAttributeByName(qualifiedName, this); } - // Override insertBefore to update element-specific properties - public insertBefore (newNode: Node, referenceNode: Node | null, suppressObservers: boolean = false): Node | null { - // Already there? - if (newNode.parentNode === this && (newNode === referenceNode || newNode.nextSibling === referenceNode)) { - return newNode; - } + /** + * Removes the specified attribute. + * + * @param namespace The namespace of the attribute + * @param localName The local name of the attribute + */ + public removeAttributeNS(namespace: string | null, localName: string): void { + expectArity(arguments, 2); + namespace = asNullableString(namespace); + localName = String(localName); - const result = super.insertBefore(newNode, referenceNode, suppressObservers); + removeAttributeByNamespaceAndLocalName(namespace, localName, this); + } - if (isElement(newNode) && newNode.parentNode === this) { - const newElement = newNode as Element; - // Update child references - this.firstElementChild = findNextElementSibling(this.firstElementChild, true) || this.firstElementChild || newElement; - this.lastElementChild = findNextElementSibling(this.lastElementChild, false) || this.lastElementChild || newElement; + /** + * Returns true if the specified attribute exists and false otherwise. + * + * @param qualifiedName The qualified name of the attribute + */ + public hasAttribute(qualifiedName: string): boolean { + expectArity(arguments, 1); + qualifiedName = String(qualifiedName); - // Update sibling references - newElement.previousElementSibling = findNextElementSibling(newNode, true); - if (newElement.previousElementSibling) { - newElement.previousElementSibling.nextElementSibling = newElement; - } - newElement.nextElementSibling = findNextElementSibling(newNode, false); - if (newElement.nextElementSibling) { - newElement.nextElementSibling.previousElementSibling = newElement; - } + // 1. If the context object is in the HTML namespace and its node document is an HTML document, then set + // qualifiedName to qualifiedName in ASCII lowercase. + // (html documents not implemented) - // Update element count - this.childElementCount += 1; - } + // 2. Return true if the context object has an attribute whose qualified name is qualifiedName, and false + // otherwise. + return getAttributeByName(qualifiedName, this) !== null; + } - return result; + /** + * Returns true if the specified attribute exists and false otherwise. + * + * @param namespace The namespace of the attribute + * @param localName The local name of the attribute + */ + public hasAttributeNS(namespace: string | null, localName: string): boolean { + expectArity(arguments, 2); + namespace = asNullableString(namespace); + localName = String(localName); + + // 1. If namespace is the empty string, set it to null. + // (handled by getAttributeByNamespaceAndLocalName, called below) + // 2. Return true if the context object has an attribute whose namespace is namespace and local name is + // localName, and false otherwise. + return getAttributeByNamespaceAndLocalName(namespace, localName, this) !== null; } - // Override removeChild to update element-specific properties - public removeChild (childNode: Node, suppressObservers: boolean = false): Node | null { - if (isElement(childNode) && childNode.parentNode === this) { - const childElement = childNode as Element; - // Update child references - if (childNode === this.firstElementChild) { - this.firstElementChild = findNextElementSibling(childNode, false); - } - if (childNode === this.lastElementChild) { - this.lastElementChild = findNextElementSibling(childNode, true); - } + /** + * Returns the specified attribute node, or null if no such attribute exists. + * + * @param qualifiedName The qualified name of the attribute + * + * @return The attribute, or null if no such attribute exists + */ + public getAttributeNode(qualifiedName: string): Attr | null { + expectArity(arguments, 1); + qualifiedName = String(qualifiedName); - // Update sibling references - if (childElement.previousElementSibling) { - childElement.previousElementSibling.nextElementSibling = childElement.nextElementSibling; - } - if (childElement.nextElementSibling) { - childElement.nextElementSibling.previousElementSibling = childElement.previousElementSibling; - } + return getAttributeByName(qualifiedName, this); + } - // Update element count - this.childElementCount -= 1; - } + /** + * Returns the specified attribute node, or null if no such attribute exists. + * + * @param namespace The namespace of the attribute + * @param localName The local name of the attribute + * + * @return The attribute, or null if no such attribute exists + */ + public getAttributeNodeNS(namespace: string | null, localName: string): Attr | null { + expectArity(arguments, 2); + namespace = asNullableString(namespace); + localName = String(localName); - return super.removeChild(childNode, suppressObservers); + return getAttributeByNamespaceAndLocalName(namespace, localName, this); } /** - * Returns whether or not the element has an attribute with the given name. + * Sets an attribute given its node * - * @param name Name of the attribute + * @param attr The attribute node to set * - * @return Whether the attribute exists on the current element + * @return The previous attribute node for the attribute */ - public hasAttribute (name: string): boolean { - return !!this._attrByName[name]; + public setAttributeNode(attr: Attr): Attr | null { + expectArity(arguments, 1); + attr = asObject(attr, Attr); + + return setAttribute(attr, this); } /** - * Returns the value of the attribute with the given name for the current element or null if the attribute - * doesn't exist. + * Sets an attribute given its node * - * @param name Name of the attribute + * @param attr The attribute node to set * - * @return The value of the attribute, or null of no such attribute exists on the current element + * @return The previous attribute node for the attribute */ - public getAttribute (name: string): string | null { - const attr = this._attrByName[name]; - return attr ? attr.value : null; + public setAttributeNodeNS(attr: Attr): Attr | null { + expectArity(arguments, 1); + attr = asObject(attr, Attr); + + return setAttribute(attr, this); } /** - * Sets the value of the attribute with the given name to the given value. + * Removes an attribute given its node * - * @param name Name of the attribute - * @param value New value for the attribute + * @param attr The attribute node to remove + * + * @return The removed attribute node */ - public setAttribute (name: string, value: string) { - // Coerce the value to a string for consistency - value = '' + value; - - const oldAttr = this._attrByName[name]; - const newAttr = { - name: name, - value: value - }; - const oldValue = oldAttr ? oldAttr.value : null; + public removeAttributeNode(attr: Attr): Attr { + expectArity(arguments, 1); + attr = asObject(attr, Attr); - // No need to trigger observers if the value doesn't actually change - if (value === oldValue) { - return; + // 1. If context object’s attribute list does not contain attr, then throw a NotFoundError. + if (this.attributes.indexOf(attr) < 0) { + throwNotFoundError('the specified attribute does not exist'); } - // Queue a mutation record - const record = new MutationRecord('attributes', this); - record.attributeName = name; - record.oldValue = oldValue; - queueMutationRecord(record); + // 2. Remove attr from context object. + removeAttribute(attr, this); - // Set value - if (oldAttr) { - oldAttr.value = value; - } - else { - this._attrByName[name] = newAttr; - this.attributes.push(newAttr); - } + // 3. Return attr. + return attr; } /** - * Removes the attribute with the given name. + * (non-standard) Creates a copy of the given node * - * @param name Name of the attribute + * @param document The node document to associate with the copy + * @param other The node to copy + * + * @return A shallow copy of the node */ - public removeAttribute (name: string) { - const attr = this._attrByName[name]; - if (!attr) { - return; + public _copy(document: Document): Element { + // 2.1. Let copy be the result of creating an element, given document, node’s local name, node’s namespace, + // node’s namespace prefix, and the value of node’s is attribute if present (or null if not). The synchronous + // custom elements flag should be unset. + const copyElement = createElement(document, this.localName, this.namespaceURI, this.prefix); + + // 2.2. For each attribute in node’s attribute list: + for (const attr of this.attributes) { + // 2.2.1. Let copyAttribute be a clone of attribute. + const copyAttribute = attr._copy(document); + + // 2.2.2. Append copyAttribute to copy. + copyElement.setAttributeNode(copyAttribute); } - // Queue mutation record - const record = new MutationRecord('attributes', this); - record.attributeName = attr.name; - record.oldValue = attr.value; - queueMutationRecord(record); + return copyElement; + } +} - // Remove the attribute - delete this._attrByName[name]; - const attrIndex = this.attributes.indexOf(attr); - this.attributes.splice(attrIndex, 1); +/** + * To create an element, given a document, localName, namespace, and optional prefix, is, and synchronous custom + * elements flag, run these steps: + * + * @param document The node document for the new element + * @param localName The local name for the new element + * @param namespace The namespace URI for the new element, or null for the null namespace + * @param prefix The prefix for the new element, or null for no prefix + * + * @return The new element + */ +export function createElement( + document: Document, + localName: string, + namespace: string | null, + prefix: string | null = null +): Element { + // 1. If prefix was not given, let prefix be null. + // (handled by default) + + // 2. If is was not given, let is be null. + // (custom elements not implemented) + + // 3. Let result be null. + let result = null; + + // 4. Let definition be the result of looking up a custom element definition given document, namespace, localName, + // and is. + // (custom elements not implemented) + + // 5. If definition is non-null, and definition’s name is not equal to its local name (i.e., definition represents a + // customized built-in element), then: + // 5.1. Let interface be the element interface for localName and the HTML namespace. + // 5.2. Set result to a new element that implements interface, with no attributes, namespace set to the HTML + // namespace, namespace prefix set to prefix, local name set to localName, custom element state set to "undefined", + // custom element definition set to null, is value set to is, and node document set to document. + // 5.3. If the synchronous custom elements flag is set, upgrade element using definition. + // 5.4. Otherwise, enqueue a custom element upgrade reaction given result and definition. + // (custom elements not implemented) + + // 6. Otherwise, if definition is non-null, then: + // 6.1. If the synchronous custom elements flag is set, then run these steps while catching any exceptions: + // 6.1.1. Let C be definition’s constructor. + // 6.1.2. Set result to the result of constructing C, with no arguments. + // 6.1.3. If result does not implement the HTMLElement interface, then throw a TypeError. + // This is meant to be a brand check to ensure that the object was allocated by the HTML element constructor. See + // webidl #97 about making this more precise. + // If this check passes, then result will already have its custom element state and custom element definition + // initialized. + // 6.1.4. If result’s attribute list is not empty, then throw a NotSupportedError. + // 6.1.5. If result has children, then throw a NotSupportedError. + // 6.1.6. If result’s parent is not null, then throw a NotSupportedError. + // 6.1.7. If result’s node document is not document, then throw a NotSupportedError. + // 6.1.8. If result’s namespace is not the HTML namespace, then throw a NotSupportedError. + // As of the time of this writing, every element that implements the HTMLElement interface is also in the HTML + // namespace, so this check is currently redundant with the above brand check. However, this is not guaranteed to be + // true forever in the face of potential specification changes, such as converging certain SVG and HTML interfaces. + // 6.1.9. If result’s local name is not equal to localName, then throw a NotSupportedError. + // 6.1.10. Set result’s namespace prefix to prefix. + // 6.1.11. Set result’s is value to null. + // If any of these steps threw an exception, then: + // 6.1.catch.1. Report the exception. + // 6.1.catch.2. Set result to a new element that implements the HTMLUnknownElement interface, with no attributes, + // namespace set to the HTML namespace, namespace prefix set to prefix, local name set to localName, custom element + // state set to "failed", custom element definition set to null, is value set to null, and node document set to + // document. + // 6.2. Otherwise: + // 6.2.1. Set result to a new element that implements the HTMLElement interface, with no attributes, namespace set + // to the HTML namespace, namespace prefix set to prefix, local name set to localName, custom element state set to + // "undefined", custom element definition set to null, is value set to null, and node document set to document. + // 6.2.2. Enqueue a custom element upgrade reaction given result and definition. + // (custom elements not implemented) + + // 7. Otherwise: + // 7.1. Let interface be the element interface for localName and namespace. + // (interfaces other than Element not implemented) + + // 7.2. Set result to a new element that implements interface, with no attributes, namespace set to namespace, + // namespace prefix set to prefix, local name set to localName, custom element state set to "uncustomized", custom + // element definition set to null, is value set to is, and node document set to document. + const context = getContext(document); + result = new context.Element(namespace, prefix, localName); + result.ownerDocument = document; + + // If namespace is the HTML namespace, and either localName is a valid custom element name or is is non-null, then + // set result’s custom element state to "undefined". + // (custom elements not implemented) + + // Return result. + return result; +} + +/** + * To get an attribute by name given a qualifiedName and element element, run these steps: + * + * @param qualifiedName The qualified name of the attribute to get + * @param element The element to get the attribute on + * + * @return The first matching attribute, or null otherwise + */ +function getAttributeByName(qualifiedName: string, element: Element): Attr | null { + // 1. If element is in the HTML namespace and its node document is an HTML document, then set qualifiedName to + // qualifiedName in ASCII lowercase. + // (html documents not implemented) + + // 2. Return the first attribute in element’s attribute list whose qualified name is qualifiedName, and null + // otherwise. + return element.attributes.find(attr => attr.name === qualifiedName) || null; +} + +/** + * To get an attribute by namespace and local name given a namespace, localName, and element element, run these steps: + * + * @param namespace Namespace for the attribute + * @param localName Local name for the attribute + * @param element The element to get the attribute on + * + * @return The first matching attribute, or null otherwise + */ +function getAttributeByNamespaceAndLocalName( + namespace: string | null, + localName: string, + element: Element +): Attr | null { + // 1. If namespace is the empty string, set it to null. + if (namespace === '') { + namespace = null; + } + + // 2. Return the attribute in element’s attribute list whose namespace is namespace and local name is localName, if + // any, and null otherwise. + return element.attributes.find(attr => attr.namespaceURI === namespace && attr.localName === localName) || null; +} + +/** + * To set an attribute given an attr and element, run these steps: + * + * @param attr The new attribute to set + * @param element The element to set attr on + * + * @return The previous attribute with attr's namespace and local name, or null if there was no such attribute + */ +function setAttribute(attr: Attr, element: Element): Attr | null { + // 1. If attr’s element is neither null nor element, throw an InUseAttributeError. + if (attr.ownerElement !== null && attr.ownerElement !== element) { + throwInUseAttributeError('attribute is in use by another element'); } - public cloneNode (deep: boolean = true, copy?: Element): Element { - const copyElement = copy as Element || new Element(this.nodeName); + // 2. Let oldAttr be the result of getting an attribute given attr’s namespace, attr’s local name, and element. + const oldAttr = getAttributeByNamespaceAndLocalName(attr.namespaceURI, attr.localName, element); - // Copy attributes - this.attributes.forEach(attr => copyElement.setAttribute(attr.name, attr.value)); + // 3. If oldAttr is attr, return attr. + if (oldAttr === attr) { + return attr; + } - return super.cloneNode(deep, copyElement) as Element; + // 4. If oldAttr is non-null, replace it by attr in element. + if (oldAttr !== null) { + replaceAttribute(oldAttr, attr, element); + } else { + // 5. Otherwise, append attr to element. + appendAttribute(attr, element); } + + // 6. Return oldAttr. + return oldAttr; +} + +/** + * To set an attribute value for an element element using a localName and value, and an optional prefix, and namespace, + * run these steps: + * + * @param element Element to set the attribute value on + * @param localName Local name of the attribute + * @param value New value of the attribute + * @param prefix Prefix of the attribute + * @param namespace Namespace of the attribute + */ +function setAttributeValue( + element: Element, + localName: string, + value: string, + prefix: string | null, + namespace: string | null +): void { + // 1. If prefix is not given, set it to null. + // 2. If namespace is not given, set it to null. + // (handled by default values) + + // 3. Let attribute be the result of getting an attribute given namespace, localName, and element. + const attribute = getAttributeByNamespaceAndLocalName(namespace, localName, element); + + // 4. If attribute is null, create an attribute whose namespace is namespace, namespace prefix is prefix, local name + // is localName, value is value, and node document is element’s node document, then append this attribute to + // element, and then return. + if (attribute === null) { + const context = getContext(element); + const attribute = new context.Attr(namespace, prefix, localName, value, element); + attribute.ownerDocument = element.ownerDocument; + appendAttribute(attribute, element); + return; + } + + // 5. Change attribute from element to value. + changeAttribute(attribute, element, value); +} + +/** + * To remove an attribute by name given a qualifiedName and element element, run these steps: + * + * @param qualifiedName Qualified name of the attribute + * @param element The element to remove the attribute from + * + * @return The removed attribute, or null if no matching attribute exists + */ +function removeAttributeByName(qualifiedName: string, element: Element): Attr | null { + // 1. Let attr be the result of getting an attribute given qualifiedName and element. + const attr = getAttributeByName(qualifiedName, element); + + // 2. If attr is non-null, remove it from element. + if (attr !== null) { + removeAttribute(attr, element); + } + + // 3. Return attr. + return attr; +} + +/** + * To remove an attribute by namespace and local name given a namespace, localName, and element element, run these + * steps: + * + * @param namespace The namespace of the attribute + * @param localName The local name of the attribute + * @param element The element to remove the attribute from + * + * @return The removed attribute, or null if no matching attribute exists + */ +function removeAttributeByNamespaceAndLocalName( + namespace: string | null, + localName: string, + element: Element +): Attr | null { + // 1. Let attr be the result of getting an attribute given namespace, localName, and element. + const attr = getAttributeByNamespaceAndLocalName(namespace, localName, element); + + // 2. If attr is non-null, remove it from element. + if (attr !== null) { + removeAttribute(attr, element); + } + + // 3. Return attr. + return attr; } diff --git a/src/Node.ts b/src/Node.ts index aa96838..87c0c5c 100644 --- a/src/Node.ts +++ b/src/Node.ts @@ -1,44 +1,46 @@ +import Element from './Element'; import Document from './Document'; import Text from './Text'; - -import MutationRecord from './mutations/MutationRecord'; -import RegisteredObservers from './mutations/RegisteredObservers'; -import queueMutationRecord from './mutations/queueMutationRecord'; - -import { getNodeIndex } from './util'; +import { ranges } from './Range'; +import RegisteredObservers from './mutation-observer/RegisteredObservers'; +import cloneNode from './util/cloneNode'; +import { expectArity } from './util/errorHelpers'; +import { preInsertNode, appendNode, replaceChildWithNode, preRemoveChild, removeNode } from './util/mutationAlgorithms'; +import { NodeType, isNodeOfType } from './util/NodeType'; +import { getNodeDocument } from './util/treeHelpers'; +import { asNullableObject, asNullableString, asObject } from './util/typeHelpers'; /** - * Internal helper used to adopt a given node into a given document. - * - * @param node Node to adopt - * @param document Document to adopt node into + * 3.4. Interface Node */ -function adopt (node: Node, document: Document) { - node.ownerDocument = document; - node.childNodes.forEach(child => adopt(child, document)); -} +export default abstract class Node { + static ELEMENT_NODE: number = NodeType.ELEMENT_NODE; + static ATTRIBUTE_NODE: number = NodeType.ATTRIBUTE_NODE; + static TEXT_NODE: number = NodeType.TEXT_NODE; + static CDATA_SECTION_NODE: number = NodeType.CDATA_SECTION_NODE; + static ENTITY_REFERENCE_NODE: number = NodeType.ENTITY_REFERENCE_NODE; // historical + static ENTITY_NODE: number = NodeType.ENTITY_NODE; // historical + static PROCESSING_INSTRUCTION_NODE: number = NodeType.PROCESSING_INSTRUCTION_NODE; + static COMMENT_NODE: number = NodeType.COMMENT_NODE; + static DOCUMENT_NODE: number = NodeType.DOCUMENT_NODE; + static DOCUMENT_TYPE_NODE: number = NodeType.DOCUMENT_TYPE_NODE; + static DOCUMENT_FRAGMENT_NODE: number = NodeType.DOCUMENT_FRAGMENT_NODE; + static NOTATION_NODE: number = NodeType.NOTATION_NODE; // historical -interface UserDataEntry { - name: string, - value: any -} + /** + * Returns the type of node, represented by a number. + */ + public abstract get nodeType(): number; -/** - * A Node is a class from which a number of DOM types inherit, and allows these various types to be treated - * (or tested) similarly. - */ -export default abstract class Node { - static ELEMENT_NODE = 1; - static TEXT_NODE = 3; - static PROCESSING_INSTRUCTION_NODE = 7; - static COMMENT_NODE = 8; - static DOCUMENT_NODE = 9; - static DOCUMENT_TYPE_NODE = 10; + /** + * Returns a string appropriate for the type of node. + */ + public abstract get nodeName(): string; /** - * An integer representing the type of the node. + * A reference to the Document node in which the current node resides. */ - public nodeType: number; + public ownerDocument: Document | null = null; /** * The parent node of the current node. @@ -46,493 +48,312 @@ export default abstract class Node { public parentNode: Node | null = null; /** - * The next sibling node of the current node (on the right, could be a Text node). + * The parent if it is an element, or null otherwise. */ - public nextSibling: Node | null = null; + public get parentElement(): Element | null { + return this.parentNode && isNodeOfType(this.parentNode, NodeType.ELEMENT_NODE) ? this.parentNode as Element : null; + } /** - * The next sibling node of the current node (on the left, could be a Text node). + * Returns true if the context object has children, and false otherwise. */ - public previousSibling: Node | null = null; + public hasChildNodes(): boolean { + return !!this.childNodes.length; + } /** - * A list of childNodes (including Text nodes) of this node. + * The node's children. + * + * Non-standard: implemented as an array rather than a NodeList. */ public childNodes: Node[] = []; /** - * The first child node of the current node. + * The first child node of the current node, or null if it has no children. */ public firstChild: Node | null = null; /** - * The last child node of the current node. + * The last child node of the current node, or null if it has no children. */ public lastChild: Node | null = null; /** - * A reference to the Document node in which the current node resides. + * The first preceding sibling of the current node, or null if it has none. */ - public ownerDocument: Document | null = null; - - // User data, use get/setUserData to access - private _userData: UserDataEntry[] = []; - private _userDataByKey: { [key: string]: UserDataEntry } = {}; + public previousSibling: Node | null = null; - // (internal) Registered mutation observers, use MutationObserver interface to manipulate - public _registeredObservers: RegisteredObservers; + /** + * The first following sibling of the current node, or null if it has none. + */ + public nextSibling: Node | null = null; /** - * @param type NodeType for the node + * The value of the node. */ - constructor (type: number) { - this.nodeType = type; - this._registeredObservers = new RegisteredObservers(this); - } + public abstract get nodeValue(): string | null; + public abstract set nodeValue(value: string | null); /** - * Internal helper used to update the firstChild and lastChild references. + * (non-standard) Each node has an associated list of registered observers. */ - private _updateFirstLast () { - this.firstChild = this.childNodes[0] || null; - this.lastChild = this.childNodes[this.childNodes.length - 1] || null; - } + public _registeredObservers: RegisteredObservers = new RegisteredObservers(this); /** - * Internal helper used to update the nextSibling and previousSibling references. + * Puts the specified node and all of its subtree into a "normalized" form. In a normalized subtree, no text nodes + * in the subtree are empty and there are no adjacent text nodes. */ - private _updateSiblings (index: number) { - if (!this.parentNode) { - // Node has been removed - if (this.nextSibling) { - this.nextSibling.previousSibling = this.previousSibling; + public normalize(): void { + // for each descendant exclusive Text node node of context object: + let node = this.firstChild; + let index = 0; + const document = getNodeDocument(this); + while (node) { + let nextNode = node.nextSibling; + if (!isNodeOfType(node, NodeType.TEXT_NODE)) { + // Process descendants + node.normalize(); + node = nextNode; + continue; } - if (this.previousSibling) { - this.previousSibling.nextSibling = this.nextSibling; + + const textNode = node as Text; + // 1. Let length be node’s length. + let length = textNode.length; + + // 2. If length is zero, then remove node and continue with the next exclusive Text node, if any. + if (length === 0) { + removeNode(node, this); + --index; + node = nextNode; + continue; } - this.nextSibling = null; - this.previousSibling = null; - return; - } - this.nextSibling = this.parentNode.childNodes[index + 1] || null; - this.previousSibling = this.parentNode.childNodes[index - 1] || null; + // 3. Let data be the concatenation of the data of node’s contiguous exclusive Text nodes (excluding + // itself), in tree order. + let data = ''; + const siblingsToRemove = []; + for ( + let sibling = textNode.nextSibling; + sibling && isNodeOfType(sibling, NodeType.TEXT_NODE); + sibling = sibling.nextSibling + ) { + data += (sibling as Text).data; + siblingsToRemove.push(sibling); + } - if (this.nextSibling) { - this.nextSibling.previousSibling = this; - } - if (this.previousSibling) { - this.previousSibling.nextSibling = this; + // 4. Replace data with node node, offset length, count 0, and data data. + if (data) { + textNode.replaceData(length, 0, data); + } + + // 5. Let currentNode be node’s next sibling. + // 6. While currentNode is an exclusive Text node: + for (let i = 0, l = siblingsToRemove.length; i < l; ++i) { + const currentNode = siblingsToRemove[i]; + const currentNodeIndex = index + i + 1; + + ranges.forEach(range => { + // 6.1. For each range whose start node is currentNode, add length to its start offset and set its + // start node to node. + if (range.startContainer === currentNode) { + range.startOffset += length; + range.startContainer = textNode; + } + + // 6.2. For each range whose end node is currentNode, add length to its end offset and set its end + // node to node. + if (range.endContainer === currentNode) { + range.endOffset += length; + range.endContainer = textNode; + } + + // 6.3. For each range whose start node is currentNode’s parent and start offset is currentNode’s + // index, set its start node to node and its start offset to length. + if (range.startContainer === this && range.startOffset === currentNodeIndex) { + range.startContainer = textNode; + range.startOffset = length; + } + + // 6.4. For each range whose end node is currentNode’s parent and end offset is currentNode’s index, + // set its end node to node and its end offset to length. + if (range.endContainer === this && range.endOffset === currentNodeIndex) { + range.endContainer = textNode; + range.endOffset = length; + } + }); + + // 6.5. Add currentNode’s length to length. + length += (currentNode as Text).length; + + // 6.6. Set currentNode to its next sibling. + // (see for-loop increment) + } + + // 7. Remove node’s contiguous exclusive Text nodes (excluding itself), in tree order. + while (siblingsToRemove.length) { + removeNode(siblingsToRemove.shift() as Node, this); + } + + // Move to next node + node = node.nextSibling; + ++index; } } /** - * Adds a node to the end of the list of children of a specified parent node. - * If the node already exists it is removed from current parent node, then added to new parent node. + * Returns a copy of the current node. * - * @param childNode Node to append + * @param deep Whether to also clone the node's descendants * - * @return The node that was inserted + * @return A copy of the current node */ - public appendChild (childNode: Node): Node | null { - return this.insertBefore(childNode, null); + public cloneNode(deep: boolean = false): Node { + return cloneNode(this, deep); } /** - * Indicates whether the given node is a descendant of the current node. + * Returns true if other is an inclusive descendant of context object, and false otherwise (including when other is + * null). * * @param childNode Node to check * * @return Whether childNode is an inclusive descendant of the current node */ - public contains (childNode: Node | null): boolean { - while (childNode && childNode != this) { - childNode = childNode.parentNode; + public contains(other: Node | null): boolean { + expectArity(arguments, 1); + other = asNullableObject(other, Node); + + while (other && other != this) { + other = other.parentNode; } - return childNode === this; + return other === this; } /** - * Inserts the specified node before a reference node as a child of the current node. - * If referenceNode is null, the new node is appended after the last child node of the current node. * - * @param newNode Node to insert - * @param referenceNode Childnode of the current node before which to insert, or null to append at the end - * @param suppressObservers (non-standard) Whether to enqueue a mutation record for the mutation * - * @return The node that was inserted + * @param namespace The namespace to look up + * + * @return The prefix for the given namespace, or null if none was found */ - public insertBefore (newNode: Node, referenceNode: Node | null, suppressObservers: boolean = false): Node | null { - // Check if referenceNode is a child - if (referenceNode && referenceNode.parentNode !== this) { - return null; - } - - // Fix using the new node as a reference - if (referenceNode === newNode) { - referenceNode = newNode.nextSibling; - } - - // Already there? - if (newNode.parentNode === this && newNode.nextSibling === referenceNode) { - return newNode; - } - - // Detach from old parent - if (newNode.parentNode) { - // This removal is never suppressed - newNode.parentNode.removeChild(newNode, false); - } - - // Adopt nodes into document - const ownerDocument = this instanceof Document ? this : this.ownerDocument as Document; - if (newNode.ownerDocument !== ownerDocument) { - adopt(newNode, ownerDocument); - } - - // Check index of reference node - const index = referenceNode ? getNodeIndex(referenceNode) : this.childNodes.length; - if (index < 0) { - return null; - } - - // Update ranges - ownerDocument._ranges.forEach(range => { - if (range.startContainer === this && range.startOffset > index) { - range.startOffset += 1; - } - if (range.endContainer === this && range.endOffset > index) { - range.endOffset += 1; - } - }); - - // Queue mutation record - if (!suppressObservers) { - const record = new MutationRecord('childList', this); - record.addedNodes.push(newNode); - record.nextSibling = referenceNode; - record.previousSibling = referenceNode ? referenceNode.previousSibling : this.lastChild; - queueMutationRecord(record); - } - - // Insert the node - newNode.parentNode = this; - this.childNodes.splice(index, 0, newNode); - this._updateFirstLast(); - newNode._updateSiblings(index); - - return newNode; - } + public abstract lookupPrefix(namespace: string | null): string | null; /** - * Puts the specified node and all of its subtree into a "normalized" form. - * In a normalized subtree, no text nodes in the subtree are empty and there are no adjacent text nodes. + * Returns the namespace for the given prefix. * - * @param recurse Whether to also normalize all descendants of the current node + * @param prefix The prefix to look up + * + * @return The namespace for the given prefix, or null if the prefix is not defined */ - public normalize (recurse: boolean = true) { - let childNode = this.firstChild; - let index = 0; - const document = this instanceof Document ? this : this.ownerDocument as Document; - while (childNode) { - let nextNode = childNode.nextSibling; - if (childNode.nodeType === Node.TEXT_NODE) { - const textChildNode = childNode as Text; - - // Delete empty text nodes - let length = textChildNode.length; - if (!length) { - this.removeChild(childNode); - --index; - } - else { - // Concatenate and collect childNode's contiguous text nodes (excluding current) - let data = ''; - const siblingsToRemove = []; - let siblingIndex: number; - let sibling: Node | null; - for (sibling = childNode.nextSibling, siblingIndex = index; - sibling && sibling.nodeType == Node.TEXT_NODE; - sibling = sibling.nextSibling, ++siblingIndex - ) { - data += (sibling as Text).data; - siblingsToRemove.push(sibling); - } - - // Append concatenated data, if any - if (data) { - textChildNode.appendData(data); - } - - // Fix ranges - for (sibling = childNode.nextSibling, siblingIndex = index + 1; - sibling && sibling.nodeType == Node.TEXT_NODE; - sibling = sibling.nextSibling, ++siblingIndex) { - - document._ranges.forEach(range => { - if (range.startContainer === sibling) { - range.setStart(childNode as Node, length + range.startOffset); - } - if (range.startContainer === this && range.startOffset == siblingIndex) { - range.setStart(childNode as Node, length); - } - if (range.endContainer === sibling) { - range.setEnd(childNode as Node, length + range.endOffset); - } - if (range.endContainer === this && range.endOffset == siblingIndex) { - range.setEnd(childNode as Node, length); - } - }); - - length += (sibling as Text).length; - }; - - // Remove contiguous text nodes (excluding current) in tree order - while (siblingsToRemove.length) { - this.removeChild(siblingsToRemove.shift() as Node); - } - - // Update next node to process - nextNode = childNode.nextSibling; - } - } - else if (recurse) { - // Recurse - childNode.normalize(); - } - - // Move to next node - childNode = nextNode; - ++index; - } - } + public abstract lookupNamespaceURI(prefix: string | null): string | null; /** - * Removes a child node from the DOM and returns the removed node. + * Return true if defaultNamespace is the same as namespace, and false otherwise. * - * @param childNode Child of the current node to remove - * @param suppressObservers (non-standard) Whether to enqueue a mutation record for the mutation + * @param namespace The namespace to check * - * @return The node that was removed + * @return Whether namespace is the default namespace */ - public removeChild (childNode: Node, suppressObservers: boolean = false): Node | null { - // Check if childNode is a child - if (childNode.parentNode !== this) { - return null; - } - - // Check index of node - const index = getNodeIndex(childNode); - if (index < 0) { - return null; - } - - // Update ranges - const document = this instanceof Document ? this : this.ownerDocument as Document; - document._ranges.forEach(range => { - if (childNode.contains(range.startContainer)) { - range.setStart(this, index); - } - if (childNode.contains(range.endContainer)) { - range.setEnd(this, index); - } - if (range.startContainer === this && range.startOffset > index) { - range.startOffset -= 1; - } - if (range.endContainer === this && range.endOffset > index) { - range.endOffset -= 1; - } - }); - - // Queue mutation record - if (!suppressObservers) { - const record = new MutationRecord('childList', this); - record.removedNodes.push(childNode); - record.nextSibling = childNode.nextSibling; - record.previousSibling = childNode.previousSibling; - queueMutationRecord(record); - } + public isDefaultNamespace(namespace: string | null): boolean { + expectArity(arguments, 1); + namespace = asNullableString(namespace); - // Add transient registered observers to detect changes in the removed subtree - for (let ancestor: Node | null = this; ancestor; ancestor = ancestor.parentNode) { - childNode._registeredObservers.appendTransientsForAncestor(ancestor._registeredObservers); + // 1. If namespace is the empty string, then set it to null. + if (namespace === '') { + namespace = null; } - // Remove the node - childNode.parentNode = null; - this.childNodes.splice(index, 1); - this._updateFirstLast(); - childNode._updateSiblings(index); + // 2. Let defaultNamespace be the result of running locate a namespace for context object using null. + const defaultNamespace = this.lookupNamespaceURI(null); - return childNode; + // 3. Return true if defaultNamespace is the same as namespace, and false otherwise. + return defaultNamespace === namespace; } /** - * Replaces the given oldChild node with the given newChild node and returns the node that was replaced - * (i.e. oldChild). + * Inserts the specified node before child within context object. * - * @param newChild Node to insert - * @param oldChild Node to remove + * If child is null, the new node is appended after the last child node of the current node. * - * @return The node that was removed + * @param node Node to insert + * @param child Childnode of the current node before which to insert, or null to append newNode at the end + * + * @return The node that was inserted */ - public replaceChild (newChild: Node, oldChild: Node): Node | null { - // Check if oldChild is a child - if (oldChild.parentNode !== this) { - return null; - } - - // Already there? - if (newChild === oldChild) { - return oldChild; - } - - // Get reference node for insert - let referenceNode = oldChild.nextSibling; - if (referenceNode === newChild) { - referenceNode = newChild.nextSibling; - } + public insertBefore(node: Node, child: Node | null): Node { + expectArity(arguments, 2); + node = asObject(node, Node); + child = asNullableObject(child, Node); - // Detach from old parent - if (newChild.parentNode) { - // This removal is never suppressed - newChild.parentNode.removeChild(newChild, false); - } - - // Adopt nodes into document - const ownerDocument = this instanceof Document ? this : this.ownerDocument as Document; - if (newChild.ownerDocument !== ownerDocument) { - adopt(newChild, ownerDocument); - } - - // Create mutation record - const record = new MutationRecord('childList', this); - record.addedNodes.push(newChild); - record.removedNodes.push(oldChild); - record.nextSibling = referenceNode; - record.previousSibling = oldChild.previousSibling; - - // Remove old child - this.removeChild(oldChild, true); - - // Insert new child - this.insertBefore(newChild, referenceNode, true); - - // Queue mutation record - queueMutationRecord(record); - - return oldChild; + return preInsertNode(node, this, child); } /** - * Retrieves the object associated to a key on this node. + * Adds node to the end of the list of children of the context object. + * + * If the node already exists it is removed from its current parent node, then added. * - * @param key Key under which the value is stored + * @param node Node to append * - * @return The associated value, or null of none exists + * @return The node that was inserted */ - public getUserData (key: string): any | null { - const data = this._userDataByKey[key]; - if (data === undefined) { - return null; - } + public appendChild(node: Node): Node { + expectArity(arguments, 1); + node = asObject(node, Node); - return data.value; + return appendNode(node, this); } /** - * Retrieves the object associated to a key on this node. User data allows a user to attach (or remove) data to - * an element, without needing to modify the DOM. Note that such data will not be preserved when imported via - * Node.importNode, as with Node.cloneNode() and Node.renameNode() operations (though Node.adoptNode does - * preserve the information), and equality tests in Node.isEqualNode() do not consider user data in making the - * assessment. - * - * This method offers the convenience of associating data with specific nodes without needing to alter the - * structure of a document and in a standard fashion, but it also means that extra steps may need to be taken - * if one wishes to serialize the information or include the information upon clone, import, or rename - * operations. + * Replaces child with node within context object and returns child. * - * @param key Key under which the value is stored - * @param data Data to store + * @param node Node to insert + * @param child Node to remove * - * @return Previous data associated with the key, or null if none existed + * @return The node that was removed */ - public setUserData (key: string, data: any = undefined) { - const oldData = this._userDataByKey[key]; - const newData = { - name: key, - value: data - }; - let oldValue = null; - - // No need to trigger observers if the value doesn't actually change - if (oldData) { - oldValue = oldData.value; - if (oldValue === data) { - return oldValue; - } - - if (data === undefined || data === null) { - // Remove user data - delete this._userDataByKey[key]; - const oldDataIndex = this._userData.indexOf(oldData); - this._userData.splice(oldDataIndex, 1); - } - else { - // Overwrite data - oldData.value = data; - } - } - else { - this._userDataByKey[key] = newData; - this._userData.push(newData); - } - - // Queue a mutation record (non-standard, but useful) - const record = new MutationRecord('userData', this); - record.attributeName = key; - record.oldValue = oldValue; - queueMutationRecord(record); + public replaceChild(node: Node, child: Node): Node { + expectArity(arguments, 2); + node = asObject(node, Node); + child = asObject(child, Node); - return oldValue; + return replaceChildWithNode(child, node, this); } /** - * Returns a copy of the current node. - * Override on subclasses and pass a shallow copy of the node in the 'copy' parameter (I.e. they create a new - * instance of their class with their specific constructor parameters.) + * Removes child from context object and returns the removed node. * - * @param deep Whether to also clone the node's descendants - * @param copy (non-standard) Copy to populate + * @param child Child of the current node to remove * - * @return A copy of the current node + * @return The node that was removed */ - public cloneNode (deep: boolean = true, copy?: Node): Node | null { - if (!copy) { - return null; - } - - // Set owner document - if (copy.nodeType !== Node.DOCUMENT_NODE) { - copy.ownerDocument = this.ownerDocument; - } + public removeChild(child: Node): Node { + expectArity(arguments, 1); + child = asObject(child, Node); - // User data is not copied, it is assumed to apply only to the original instance - - // Recurse if required - if (deep) { - for (let child = this.firstChild; child; child = child.nextSibling) { - copy.appendChild(child.cloneNode(true) as Node); - } - } - - return copy; + return preRemoveChild(child, this); } + + /** + * (non-standard) Creates a copy of the context object, not including its children. + * + * @param document The node document to associate with the copy + * + * @return A shallow copy of the context object + */ + public abstract _copy(document: Document): Node; } -(Node.prototype as any).ELEMENT_NODE = 1; -(Node.prototype as any).TEXT_NODE = 3; -(Node.prototype as any).PROCESSING_INSTRUCTION_NODE = 7; -(Node.prototype as any).COMMENT_NODE = 8; -(Node.prototype as any).DOCUMENT_NODE = 9; -(Node.prototype as any).DOCUMENT_TYPE_NODE = 10; +(Node.prototype as any).ELEMENT_NODE = NodeType.ELEMENT_NODE; +(Node.prototype as any).ATTRIBUTE_NODE = NodeType.ATTRIBUTE_NODE; +(Node.prototype as any).TEXT_NODE = NodeType.TEXT_NODE; +(Node.prototype as any).CDATA_SECTION_NODE = NodeType.CDATA_SECTION_NODE; +(Node.prototype as any).ENTITY_REFERENCE_NODE = NodeType.ENTITY_REFERENCE_NODE; // historical +(Node.prototype as any).ENTITY_NODE = NodeType.ENTITY_NODE; // historical +(Node.prototype as any).PROCESSING_INSTRUCTION_NODE = NodeType.PROCESSING_INSTRUCTION_NODE; +(Node.prototype as any).COMMENT_NODE = NodeType.COMMENT_NODE; +(Node.prototype as any).DOCUMENT_NODE = NodeType.DOCUMENT_NODE; +(Node.prototype as any).DOCUMENT_TYPE_NODE = NodeType.DOCUMENT_TYPE_NODE; +(Node.prototype as any).DOCUMENT_FRAGMENT_NODE = NodeType.DOCUMENT_FRAGMENT_NODE; +(Node.prototype as any).NOTATION_NODE = NodeType.NOTATION_NODE; // historical diff --git a/src/ProcessingInstruction.ts b/src/ProcessingInstruction.ts index d57bb6e..89ec73d 100644 --- a/src/ProcessingInstruction.ts +++ b/src/ProcessingInstruction.ts @@ -1,35 +1,50 @@ import CharacterData from './CharacterData'; -import Node from './Node'; +import Document from './Document'; +import { getContext } from './context/Context'; +import { NodeType } from './util/NodeType'; /** - * A processing instruction provides an opportunity for application-specific instructions to be embedded within - * XML and which can be ignored by XML processors which do not support processing their instructions (outside - * of their having a place in the DOM). - * - * A Processing instruction is distinct from a XML Declaration which is used for other information about the - * document such as encoding and which appear (if it does) as the first item in the document. - * - * User-defined processing instructions cannot begin with 'xml', as these are reserved (e.g., as used in - * ). + * 3.13. Interface ProcessingInstruction */ export default class ProcessingInstruction extends CharacterData { - /** - * The string that goes after the determineLengthOfNode(node)) { + throwIndexSizeError('Can not set a range past the end of the node'); + } + + // 3. Let bp be the boundary point (node, offset). + // 4.a. If these steps were invoked as "set the start" + // 4.a.1. If bp is after the range’s end, or if range’s root is not equal to node’s root, set range’s end to bp. + const rootOfNode = getRootOfNode(node); + const rootOfRange = getRootOfRange(this); + if ( + rootOfNode !== rootOfRange || + compareBoundaryPointPositions(node, offset, this.endContainer, this.endOffset) === POSITION_AFTER + ) { + this.endContainer = node; + this.endOffset = offset; + } + // 4.a.2. Set range’s start to bp. + this.startContainer = node; + this.startOffset = offset; + + // 4.b. If these steps were invoked as "set the end" + // 4.b.1. If bp is before the range’s start, or if range’s root is not equal to node’s root, set range’s start + // to bp. + // 4.b.2. Set range’s end to bp. + // (see Range#setEnd for this branch) + } + + /** + * Sets the end boundary point of the range. + * + * @param node The new end container + * @param offset The new end offset + */ + setEnd(node: Node, offset: number): void { + expectArity(arguments, 2); + node = asObject(node, Node); + offset = asUnsignedLong(offset); + + // 1. If node is a doctype, then throw an InvalidNodeTypeError. + if (isNodeOfType(node, NodeType.DOCUMENT_TYPE_NODE)) { + throwInvalidNodeTypeError('Can not set a range under a doctype node'); + } + + // 2. If offset is greater than node’s length, then throw an IndexSizeError. + if (offset > determineLengthOfNode(node)) { + throwIndexSizeError('Can not set a range past the end of the node'); + } + + // 3. Let bp be the boundary point (node, offset). + // 4.a. If these steps were invoked as "set the start" + // 4.a.1. If bp is after the range’s end, or if range’s root is not equal to node’s root, set range’s end to bp. + // 4.a.2. Set range’s start to bp. + // (see Range#setStart for this branch) + + // 4.b. If these steps were invoked as "set the end" + // 4.b.1. If bp is before the range’s start, or if range’s root is not equal to node’s root, set range’s start + // to bp. + const rootOfNode = getRootOfNode(node); + const rootOfRange = getRootOfRange(this); + if ( + rootOfNode !== rootOfRange || + compareBoundaryPointPositions(node, offset, this.endContainer, this.endOffset) === POSITION_BEFORE + ) { + this.startContainer = node; + this.startOffset = offset; + } + // 4.b.2. Set range’s end to bp. + this.endContainer = node; + this.endOffset = offset; + } + + /** + * Sets the start boundary point of the range to the position just before the given node. + * + * @param node The node to set the range's start before + */ + setStartBefore(node: Node): void { + expectArity(arguments, 1); + node = asObject(node, Node); + + // 1. Let parent be node’s parent. + const parent = node.parentNode; + + // 2. If parent is null, then throw an InvalidNodeTypeError. + if (parent === null) { + return throwInvalidNodeTypeError('Can not set range before node without a parent'); + } + + // 3. Set the start of the context object to boundary point (parent, node’s index). + this.setStart(parent, getNodeIndex(node)); + } + + /** + * Sets the start boundary point of the range to the position just after the given node. + * + * @param node The node to set the range's start before + */ + setStartAfter(node: Node): void { + expectArity(arguments, 1); + node = asObject(node, Node); + + // 1. Let parent be node’s parent. + const parent = node.parentNode; + + // 2. If parent is null, then throw an InvalidNodeTypeError. + if (parent === null) { + return throwInvalidNodeTypeError('Can not set range before node without a parent'); + } + + // 3. Set the start of the context object to boundary point (parent, node’s index plus one). + this.setStart(parent, getNodeIndex(node) + 1); + } + + /** + * Sets the end boundary point of the range to the position just before the given node. + * + * @param node The node to set the range's end before + */ + setEndBefore(node: Node): void { + expectArity(arguments, 1); + node = asObject(node, Node); + + // 1. Let parent be node’s parent. + const parent = node.parentNode; + + // 2. If parent is null, then throw an InvalidNodeTypeError. + if (parent === null) { + return throwInvalidNodeTypeError('Can not set range before node without a parent'); + } + + // 3. Set the end of the context object to boundary point (parent, node’s index). + this.setEnd(parent, getNodeIndex(node)); + } + + /** + * Sets the end boundary point of the range to the position just after the given node. + * + * @param node The node to set the range's end before + */ + setEndAfter(node: Node): void { + expectArity(arguments, 1); + node = asObject(node, Node); + + // 1. Let parent be node’s parent. + const parent = node.parentNode; + + // 2. If parent is null, then throw an InvalidNodeTypeError. + if (parent === null) { + return throwInvalidNodeTypeError('Can not set range before node without a parent'); + } + + // 3. Set the end of the context object to boundary point (parent, node’s index plus one). + this.setEnd(parent, getNodeIndex(node) + 1); + } + + /** + * Sets the range's boundary points to the same position. + * + * @param toStart If true, set both points to the start of the range, otherwise set them to the end + */ + collapse(toStart: boolean = false): void { + if (toStart) { + this.endContainer = this.startContainer; + this.endOffset = this.startOffset; + } else { + this.startContainer = this.endContainer; + this.startOffset = this.endOffset; + } + } + + selectNode(node: Node): void { + expectArity(arguments, 1); + node = asObject(node, Node); + + // 1. Let parent be node’s parent. + let parent = node.parentNode; + + // 2. If parent is null, throw an InvalidNodeTypeError. + if (parent === null) { + return throwInvalidNodeTypeError('Can not select node with null parent'); + } + + // 3. Let index be node’s index. + const index = getNodeIndex(node); + + // 4. Set range’s start to boundary point (parent, index). + this.startContainer = parent; + this.startOffset = index; + + // 5. Set range’s end to boundary point (parent, index plus one). + this.endContainer = parent; + this.endOffset = index + 1; + } + + selectNodeContents(node: Node): void { + expectArity(arguments, 1); + node = asObject(node, Node); + + // 1. If node is a doctype, throw an InvalidNodeTypeError. + if (isNodeOfType(node, NodeType.DOCUMENT_TYPE_NODE)) { + throwInvalidNodeTypeError('Can not place range inside a doctype node'); + } + + // 2. Let length be the length of node. + const length = determineLengthOfNode(node); + + // 3. Set start to the boundary point (node, 0). + this.startContainer = node; + this.startOffset = 0; + + // 4. Set end to the boundary point (node, length). + this.endContainer = node; + this.endOffset = length; + } + + static START_TO_START = 0; + static START_TO_END = 1; + static END_TO_END = 2; + static END_TO_START = 3; + + compareBoundaryPoints(how: number, sourceRange: Range): number { + expectArity(arguments, 2); + sourceRange = asObject(sourceRange, Range); + + // 1. If how is not one of START_TO_START, START_TO_END, END_TO_END, and END_TO_START, then throw a + // NotSupportedError. + if ( + how !== Range.START_TO_START && + how !== Range.START_TO_END && + how !== Range.END_TO_END && + how !== Range.END_TO_START + ) { + throwNotSupportedError('Unsupported comparison type'); + } + + // 2. If context object’s root is not the same as sourceRange’s root, then throw a WrongDocumentError. + if (getRootOfRange(this) !== getRootOfRange(sourceRange)) { + throwWrongDocumentError('Can not compare positions of ranges in different trees'); + } + + // 3. If how is: + switch (how) { + // START_TO_START: + case Range.START_TO_START: + // Let this point be the context object’s start. Let other point be sourceRange’s start. + return compareBoundaryPointPositions( + // this point + this.startContainer, + this.startOffset, + // other point + sourceRange.startContainer, + sourceRange.startOffset + ); + + // START_TO_END: + case Range.START_TO_END: + // Let this point be the context object’s end. Let other point be sourceRange’s start. + return compareBoundaryPointPositions( + // this point + this.endContainer, + this.endOffset, + // other point + sourceRange.startContainer, + sourceRange.startOffset + ); + + // END_TO_END: + case Range.END_TO_END: + // Let this point be the context object’s end. Let other point be sourceRange’s end. + return compareBoundaryPointPositions( + // this point + this.endContainer, + this.endOffset, + // other point + sourceRange.endContainer, + sourceRange.endOffset + ); + + // END_TO_START: + default: + // Let this point be the context object’s start. Let other point be sourceRange’s end. + return compareBoundaryPointPositions( + // this point + this.startContainer, + this.startOffset, + // other point, + sourceRange.endContainer, + sourceRange.endOffset + ); + } + + // 4. If the position of this point relative to other point is + // before: Return −1. + // equal: Return 0. + // after: Return 1. + // (handled in switch above) + } + + /** + * Returns a range with the same start and end as the context object. + * + * @return A copy of the context object + */ + cloneRange(): Range { + const context = getContext(this); + const range = new context.Range(); + range.startContainer = this.startContainer; + range.startOffset = this.startOffset; + range.endContainer = this.endContainer; + range.endOffset = this.endOffset; + return range; + } + + /** + * Stops tracking the range. + * + * (non-standard) According to the spec, this method must do nothing. However, as it is not possible to rely on + * garbage collection to determine when to stop updating a range for node mutations, this implementation requires + * calling detach to stop such updates from affecting the range. + */ + detach(): void { + const index = ranges.indexOf(this); + if (index >= 0) { + ranges.splice(index, 1); + } + } + + /** + * Returns true if the given point is after or equal to the start point and before or equal to the end point of the + * context object. + * + * @param node Node of point to check + * @param offset Offset of point to check + * + * @return Whether the point is in the range + */ + isPointInRange(node: Node, offset: number): boolean { + expectArity(arguments, 2); + node = asObject(node, Node); + offset = asUnsignedLong(offset); + + // 1. If node’s root is different from the context object’s root, return false. + if (getRootOfNode(node) !== getRootOfRange(this)) { + return false; + } + + // 2. If node is a doctype, then throw an InvalidNodeTypeError. + if (isNodeOfType(node, NodeType.DOCUMENT_TYPE_NODE)) { + throwInvalidNodeTypeError('Point can not be under a doctype'); + } + + // 3. If offset is greater than node’s length, then throw an IndexSizeError. + if (offset > determineLengthOfNode(node)) { + throwIndexSizeError('Offset should not be past the end of node'); + } + + // 4. If (node, offset) is before start or after end, return false. + if ( + compareBoundaryPointPositions(node, offset, this.startContainer, this.startOffset) === POSITION_BEFORE || + compareBoundaryPointPositions(node, offset, this.endContainer, this.endOffset) === POSITION_AFTER + ) { + return false; + } + + // 5. Return true. + return true; + } + + /** + * Compares the given point to the range's boundary points. + * + * @param node Node of point to check + * @param offset Offset of point to check + * + * @return -1, 0 or 1 depending on whether the point is before, inside or after the range, respectively + */ + comparePoint(node: Node, offset: number): number { + expectArity(arguments, 2); + node = asObject(node, Node); + offset = asUnsignedLong(offset); + + // 1. If node’s root is different from the context object’s root, then throw a WrongDocumentError. + if (getRootOfNode(node) !== getRootOfRange(this)) { + throwWrongDocumentError('Can not compare point to range in different trees'); + } + + // 2. If node is a doctype, then throw an InvalidNodeTypeError. + if (isNodeOfType(node, NodeType.DOCUMENT_TYPE_NODE)) { + throwInvalidNodeTypeError('Point can not be under a doctype'); + } + + // 3. If offset is greater than node’s length, then throw an IndexSizeError. + if (offset > determineLengthOfNode(node)) { + throwIndexSizeError('Offset should not be past the end of node'); + } + + // 4. If (node, offset) is before start, return −1. + if (compareBoundaryPointPositions(node, offset, this.startContainer, this.startOffset) === POSITION_BEFORE) { + return -1; + } + + // 5. If (node, offset) is after end, return 1. + if (compareBoundaryPointPositions(node, offset, this.endContainer, this.endOffset) === POSITION_AFTER) { + return 1; + } + + // 6. Return 0. + return 0; + } + + /** + * Returns true if range overlaps the range from before node to after node. + * + * @param node The node to check + * + * @return Whether the range intersects node + */ + intersectsNode(node: Node): boolean { + expectArity(arguments, 1); + node = asObject(node, Node); + + // 1. If node’s root is different from the context object’s root, return false. + if (getRootOfNode(node) !== getRootOfRange(this)) { + return false; + } + + // 2. Let parent be node’s parent. + const parent = node.parentNode; + + // 3. If parent is null, return true. + if (parent === null) { + return true; + } + + // 4. Let offset be node’s index. + const offset = getNodeIndex(node); + + // 5. If (parent, offset) is before end and (parent, offset + 1) is after start, return true. + // 6. Return false. + return ( + compareBoundaryPointPositions(parent, offset, this.endContainer, this.endOffset) === POSITION_BEFORE && + compareBoundaryPointPositions(parent, offset + 1, this.startContainer, this.startOffset) === POSITION_AFTER + ); + } +} + +const POSITION_BEFORE = -1; +const POSITION_EQUAL = 0; +const POSITION_AFTER = 1; + +/** + * If the two nodes of boundary points (node A, offset A) and (node B, offset B) have the same root, the position of the + * first relative to the second is either before, equal, or after. + * + * Note: for efficiency reasons, this implementation deviates from the algorithm given in 4.2. + * + * This implementation assumes it is called on nodes under the same root. + * + * @param nodeA First boundary point's node + * @param offsetA First boundary point's offset + * @param nodeB Second boundary point's node + * @param offsetB Second boundary point's offset + * + * @return -1, 0 or 1, depending on the boundary points' relative positions + */ +function compareBoundaryPointPositions(nodeA: Node, offsetA: number, nodeB: Node, offsetB: number): number { + if (nodeA !== nodeB) { + const ancestors1 = getInclusiveAncestors(nodeA); + const ancestors2 = getInclusiveAncestors(nodeB); + + // Skip common parents + while (ancestors1[0] && ancestors2[0] && ancestors1[0] === ancestors2[0]) { + ancestors1.shift(); + ancestors2.shift(); + } + + // Compute offsets at the level under the last common parent. Add 0.5 to bias positions inside the parent vs. + // those before or after. + if (ancestors1.length) { + offsetA = getNodeIndex(ancestors1[0]) + 0.5; + } + if (ancestors2.length) { + offsetB = getNodeIndex(ancestors2[0]) + 0.5; + } + } + + // Compare positions at this level + if (offsetA === offsetB) { + return POSITION_EQUAL; + } + return offsetA < offsetB ? POSITION_BEFORE : POSITION_AFTER; +} + +/** + * The root of a range is the root of its start node. + * + * @param range The range to get the root of + * + * @return The root of range + */ +function getRootOfRange(range: Range): Node { + return getRootOfNode(range.startContainer); +} diff --git a/src/Text.ts b/src/Text.ts index aa2b289..1f4556b 100644 --- a/src/Text.ts +++ b/src/Text.ts @@ -1,98 +1,140 @@ -import CharacterData from './CharacterData'; +import { replaceData, substringData, default as CharacterData } from './CharacterData'; import Document from './Document'; -import Node from './Node'; - -import { getNodeIndex } from './util'; +import { ranges } from './Range'; +import { getContext } from './context/Context'; +import { expectArity, throwIndexSizeError } from './util/errorHelpers'; +import { insertNode } from './util/mutationAlgorithms'; +import { NodeType } from './util/NodeType'; +import { getNodeIndex } from './util/treeHelpers'; +import { asUnsignedLong } from './util/typeHelpers'; /** - * The Text interface represents the textual content of an Element node. If an element has no markup within its - * content, it has a single child implementing Text that contains the element's text. However, if the element - * contains markup, it is parsed into information items and Text nodes that form its children. - * - * New documents have a single Text node for each block of text. Over time, more Text nodes may be created as - * the document's content changes. The Node.normalize() method merges adjacent Text objects back into a single - * node for each block of text. + * 3.11. Interface Text */ export default class Text extends CharacterData { + // Node + + public get nodeType(): number { + return NodeType.TEXT_NODE; + } + + public get nodeName(): string { + return '#text'; + } + + // Text + /** - * @param content Content for the text node + * Returns a new Text node whose data is data and node document is current global object’s associated Document. + * + * @param data The data for the new text node */ - constructor (content: string) { - super(Node.TEXT_NODE, content); + constructor(data: string = '') { + super(data); + + const context = getContext(this); + this.ownerDocument = context.document; } /** - * Breaks the Text node into two nodes at the specified offset, keeping both nodes in the tree as siblings. + * Splits data at the given offset and returns the remainder as Text node. * - * After the split, the current node contains all the content up to the specified offset point, and a newly - * created node of the same type contains the remaining text. The newly created node is returned to the caller. - * If the original node had a parent, the new node is inserted as the next sibling of the original node. - * If the offset is equal to the length of the original node, the newly created node has no data. + * @param offset The offset at which to split * - * Separated text nodes can be concatenated using the Node.normalize() method. + * @return a text node containing the second half of the split node's data + */ + public splitText(offset: number): Text { + expectArity(arguments, 1); + offset = asUnsignedLong(offset); + + return splitText(this, offset); + } + + /** + * (non-standard) Creates a copy of the context object, not including its children. * - * @param offset Offset at which to split + * @param document The node document to associate with the copy * - * @return The new text node created to hold the second half of the split content + * @return A shallow copy of the context object */ - public splitText (offset: number): Text { - // Check offset - const length = this.length; - if (offset < 0) { - offset = 0; - } - if (offset > length) { - offset = length; - } - - const count = length - offset; - const newData = this.substringData(offset, count); - const document = this.ownerDocument as Document; - const newNode = document.createTextNode(newData); - - // If the current node is part of a tree, insert the new node - if (this.parentNode) { - this.parentNode.insertBefore(newNode, this.nextSibling); - - // Update ranges - var nodeIndex = getNodeIndex(this); - document._ranges.forEach(range => { - if (range.startContainer === this.parentNode && range.startOffset === nodeIndex + 1) { - range.setStart(range.startContainer as Node, range.startOffset + 1); - } - if (range.endContainer === this.parentNode && range.endOffset === nodeIndex + 1) { - range.setEnd(range.endContainer as Node, range.endOffset + 1); - } - if (range.startContainer === this && range.startOffset > offset) { - range.setStart(newNode, range.startOffset - offset); - } - if (range.endContainer === this && range.endOffset > offset) { - range.setEnd(newNode, range.endOffset - offset); - } - }); - } - - // Truncate our own data - this.deleteData(offset, count); - - if (!this.parentNode) { - // Update ranges - document._ranges.forEach(range => { - if (range.startContainer === this && range.startOffset > offset) { - range.setStart(range.startContainer, offset); - } - if (range.endContainer === this && range.endOffset > offset) { - range.setEnd(range.endContainer, offset); - } - }); - } - - // Return the new node - return newNode; + public _copy(document: Document): Text { + // Set copy’s data, to that of node. + const context = getContext(document); + const copy = new context.Text(this.data); + copy.ownerDocument = document; + return copy; } +} - public cloneNode (deep: boolean = true, copy?: Text): Text { - copy = copy || new Text(this.data); - return super.cloneNode(deep, copy) as Text; +/** + * To split a Text node node with offset offset, run these steps: + * + * @param node The text node to split + * @param offset The offset to split at + * + * @return a text node containing the second half of the split node's data + */ +function splitText(node: Text, offset: number): Text { + // 1. Let length be node’s length. + const length = node.length; + + // 2. If offset is greater than length, then throw an IndexSizeError. + if (offset > length) { + throwIndexSizeError("can not split past the node's length"); + } + + // 3. Let count be length minus offset. + const count = length - offset; + + // 4. Let new data be the result of substringing data with node node, offset offset, and count count. + const newData = substringData(node, offset, count); + + // 5. Let new node be a new Text node, with the same node document as node. Set new node’s data to new data. + const context = getContext(node); + const newNode = new context.Text(newData); + newNode.ownerDocument = node.ownerDocument; + + // 6. Let parent be node’s parent. + const parent = node.parentNode; + + // 7. If parent is not null, then: + if (parent !== null) { + // 7.1. Insert new node into parent before node’s next sibling. + insertNode(newNode, parent, node.nextSibling); + + const indexOfNodePlusOne = getNodeIndex(node) + 1; + ranges.forEach(range => { + // 7.2. For each range whose start node is node and start offset is greater than offset, set its start node + // to new node and decrease its start offset by offset. + if (range.startContainer === node && range.startOffset > offset) { + range.startContainer = newNode; + range.startOffset -= offset; + } + + // 7.3. For each range whose end node is node and end offset is greater than offset, set its end node to new + // node and decrease its end offset by offset. + if (range.endContainer === node && range.endOffset > offset) { + range.endContainer = newNode; + range.endOffset -= offset; + } + + // 7.4. For each range whose start node is parent and start offset is equal to the index of node + 1, + // increase its start offset by one. + if (range.startContainer === parent && range.startOffset === indexOfNodePlusOne) { + range.startOffset += 1; + } + + // 7.5. For each range whose end node is parent and end offset is equal to the index of node + 1, increase + // its end offset by one. + if (range.endContainer === parent && range.endOffset === indexOfNodePlusOne) { + range.endOffset += 1; + } + }); } + + // 8. Replace data with node node, offset offset, count count, and data the empty string. + replaceData(node, offset, count, ''); + + // 9. Return new node. + return newNode; } diff --git a/src/XMLDocument.ts b/src/XMLDocument.ts new file mode 100644 index 0000000..cdd2149 --- /dev/null +++ b/src/XMLDocument.ts @@ -0,0 +1,19 @@ +import Document from './Document'; +import { getContext } from './context/Context'; + +export default class XMLDocument extends Document { + /** + * (non-standard) Creates a copy of the context object, not including its children. + * + * @param document The node document to associate with the copy + * + * @return A shallow copy of the context object + */ + public _copy(document: Document): XMLDocument { + // Set copy’s encoding, content type, URL, origin, type, and mode, to those of node. + // (properties not implemented) + + const context = getContext(document); + return new context.XMLDocument(); + } +} diff --git a/src/context/Context.ts b/src/context/Context.ts new file mode 100644 index 0000000..9e4867e --- /dev/null +++ b/src/context/Context.ts @@ -0,0 +1,75 @@ +import Attr from '../Attr'; +import CDATASection from '../CDATASection'; +import Comment from '../Comment'; +import Document from '../Document'; +import DocumentFragment from '../DocumentFragment'; +import DocumentType from '../DocumentType'; +import DOMImplementation from '../DOMImplementation'; +import Element from '../Element'; +import Node from '../Node'; +import ProcessingInstruction from '../ProcessingInstruction'; +import Range from '../Range'; +import Text from '../Text'; +import XMLDocument from '../XMLDocument'; + +import { NodeType } from '../util/NodeType'; + +export type AttrConstructor = new (namespace: string | null, prefix: + | string + | null, localName: string, value: string, element: Element | null) => Attr; +export type CDATASectionConstructor = new (data: string) => CDATASection; +export type CommentConstructor = new (data: string) => Comment; +export type DocumentConstructor = new () => Document; +export type DocumentFragmentConstructor = new () => DocumentFragment; +export type DocumentTypeConstructor = new (name: string, publicId?: string, systemId?: string) => DocumentType; +export type DOMImplementationConstructor = new (document: Document) => DOMImplementation; +export type ElementConstructor = new (namespace: string | null, prefix: string | null, localName: string) => Element; +export type ProcessingInstructionConstructor = new (target: string, data: string) => ProcessingInstruction; +export type RangeConstructor = new () => Range; +export type TextConstructor = new (data: string) => Text; +export type XMLDocumentConstructor = new () => XMLDocument; + +export interface Context { + document: Document; + + Attr: AttrConstructor; + CDATASection: CDATASectionConstructor; + Comment: CommentConstructor; + Document: DocumentConstructor; + DocumentFragment: DocumentFragmentConstructor; + DocumentType: DocumentTypeConstructor; + DOMImplementation: DOMImplementationConstructor; + Element: ElementConstructor; + ProcessingInstruction: ProcessingInstructionConstructor; + Range: RangeConstructor; + Text: TextConstructor; + XMLDocument: XMLDocumentConstructor; +} + +/** + * The DefaultContext is comparable to the global object in that it tracks its associated document. It also serves as a + * way to inject the constructors for the constructable types, avoiding cyclic dependencies. + */ +export class DefaultContext implements Context { + public document: Document; + + public Attr: AttrConstructor; + public CDATASection: CDATASectionConstructor; + public Comment: CommentConstructor; + public Document: DocumentConstructor; + public DocumentFragment: DocumentFragmentConstructor; + public DocumentType: DocumentTypeConstructor; + public DOMImplementation: DOMImplementationConstructor; + public Element: ElementConstructor; + public ProcessingInstruction: ProcessingInstructionConstructor; + public Range: RangeConstructor; + public Text: TextConstructor; + public XMLDocument: XMLDocumentConstructor; +} + +// TODO: make it possible to create multiple contexts by binding constructors to each instance +export const defaultContext = new DefaultContext(); + +export function getContext(instance: Node | Range): Context { + return defaultContext; +} diff --git a/src/globals.ts b/src/globals.ts deleted file mode 100644 index aa5edf0..0000000 --- a/src/globals.ts +++ /dev/null @@ -1,3 +0,0 @@ -import DOMImplementation from './DOMImplementation'; - -export const implementation = new DOMImplementation() diff --git a/src/index.ts b/src/index.ts index 3550e8e..fd06332 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,49 +1,51 @@ +export { default as Attr } from './Attr'; +export { default as CDATASection } from './CDATASection'; +export { default as CharacterData } from './CharacterData'; +export { default as Comment } from './Comment'; +export { default as Document } from './Document'; +export { default as DocumentFragment } from './DocumentFragment'; +export { default as DocumentType } from './DocumentType'; +export { default as DOMImplementation } from './DOMImplementation'; +export { default as Element } from './Element'; +export { default as Node } from './Node'; +export { default as ProcessingInstruction } from './ProcessingInstruction'; +export { default as Range } from './Range'; +export { default as Text } from './Text'; +export { default as XMLDocument } from './XMLDocument'; +export { default as MutationObserver } from './mutation-observer/MutationObserver'; +export { default as MutationRecord } from './mutation-observer/MutationRecord'; + +// To avoid cyclic dependencies and enable multiple contexts with their own constructors later, inject all constructors +// as well as the global document into the default context (i.e., global object) here. +import { defaultContext } from './context/Context'; + +import Attr from './Attr'; +import CDATASection from './CDATASection'; +import Comment from './Comment'; import Document from './Document'; -import Node from './Node'; -import Element from './Element'; -import Range from './selections/Range'; -import MutationObserver from './mutations/MutationObserver'; - +import DocumentFragment from './DocumentFragment'; +import DocumentType from './DocumentType'; import DOMImplementation from './DOMImplementation'; -import { implementation } from './globals'; - -export default { - /** - * The DOMImplementation instance. - */ - implementation, - - /** - * Creates a new Document and returns it. - * - * @return The newly created Document. - */ - createDocument (): Document { - return implementation.createDocument(null, ''); - }, - - /** - * The Document constructor. - */ - Document, - - /** - * The Node constructor. - */ - Node, - - /** - * The Element constructor. - */ - Element, - - /** - * The Range constructor. - */ - Range, - - /** - * The MutationObserver constructor. - */ - MutationObserver -}; +import Element from './Element'; +import ProcessingInstruction from './ProcessingInstruction'; +import Range from './Range'; +import Text from './Text'; +import XMLDocument from './XMLDocument'; +import MutationObserver from './mutation-observer/MutationObserver'; + +// Document to associate with the global object +export const document = new Document(); +defaultContext.document = document; + +defaultContext.Attr = Attr; +defaultContext.CDATASection = CDATASection; +defaultContext.Comment = Comment; +defaultContext.Document = Document; +defaultContext.DocumentFragment = DocumentFragment; +defaultContext.DocumentType = DocumentType; +defaultContext.DOMImplementation = DOMImplementation; +defaultContext.Element = Element; +defaultContext.ProcessingInstruction = ProcessingInstruction; +defaultContext.Range = Range; +defaultContext.Text = Text; +defaultContext.XMLDocument = XMLDocument; diff --git a/src/mixins.ts b/src/mixins.ts new file mode 100644 index 0000000..419887f --- /dev/null +++ b/src/mixins.ts @@ -0,0 +1,112 @@ +import CharacterData from './CharacterData'; +import Document from './Document'; +import DocumentFragment from './DocumentFragment'; +import Element from './Element'; +import Node from './Node'; + +import { NodeType, isNodeOfType } from './util/NodeType'; + +/** + * 3.2.4. Mixin NonElementParentNode + */ +export interface NonElementParentNode {} +// Document implements NonElementParentNode; +// DocumentFragment implements NonElementParentNode; + +/** + * 3.2.6. Mixin ParentNode + */ +export interface ParentNode { + readonly children: Element[]; + + firstElementChild: Element | null; + lastElementChild: Element | null; + childElementCount: number; +} +// Document implements ParentNode; +// DocumentFragment implements ParentNode; +// Element implements ParentNode; + +export function asParentNode(node: Node): ParentNode | null { + // This is only called from treeMutations.js, where node can never be anything other than these + /* istanbul ignore else */ + if (isNodeOfType(node, NodeType.ELEMENT_NODE, NodeType.DOCUMENT_NODE, NodeType.DOCUMENT_FRAGMENT_NODE)) { + return node as Element | Document | DocumentFragment; + } + + /* istanbul ignore next */ + return null; +} + +/** + * Returns the element children of node. + * + * (Non-standard) According to the spec, the children getter should return a live HTMLCollection. This implementation + * returns a static array instead. + * + * @param node The node to get element children of + * + * @return The + */ +export function getChildren(node: ParentNode): Element[] { + const elements: Element[] = []; + for (let child = node.firstElementChild; child; child = child.nextElementSibling) { + elements.push(child); + } + return elements; +} + +/** + * 3.2.7. Mixin NonDocumentTypeChildNode + */ +export interface NonDocumentTypeChildNode { + readonly previousElementSibling: Element | null; + readonly nextElementSibling: Element | null; +} +// Element implements NonDocumentTypeChildNode; +// CharacterData implements NonDocumentTypeChildNode; + +export function asNonDocumentTypeChildNode(node: Node): NonDocumentTypeChildNode | null { + if ( + isNodeOfType( + node, + NodeType.ELEMENT_NODE, + NodeType.COMMENT_NODE, + NodeType.PROCESSING_INSTRUCTION_NODE, + NodeType.TEXT_NODE, + NodeType.CDATA_SECTION_NODE + ) + ) { + return node as Element | CharacterData; + } + + return null; +} + +export function getPreviousElementSibling(node: Node): Element | null { + for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling) { + if (isNodeOfType(sibling, NodeType.ELEMENT_NODE)) { + return sibling as Element; + } + } + + return null; +} + +export function getNextElementSibling(node: Node): Element | null { + for (let sibling = node.nextSibling; sibling; sibling = sibling.nextSibling) { + if (isNodeOfType(sibling, NodeType.ELEMENT_NODE)) { + return sibling as Element; + } + } + + return null; +} + +/** + * 3.2.8. Mixin ChildNode + */ +export interface ChildNode {} +// DocumentType implements ChildNode; +// Element implements ChildNode; +// CharacterData implements ChildNode; diff --git a/src/mutation-observer/MutationObserver.ts b/src/mutation-observer/MutationObserver.ts new file mode 100644 index 0000000..0b73646 --- /dev/null +++ b/src/mutation-observer/MutationObserver.ts @@ -0,0 +1,187 @@ +import MutationRecord from './MutationRecord'; +import NotifyList from './NotifyList'; +import RegisteredObserver from './RegisteredObserver'; +import Node from '../Node'; +import { expectArity } from '../util/errorHelpers'; +import { asObject } from '../util/typeHelpers'; + +export interface MutationObserverInit { + /** + * Whether to observe childList mutations. + */ + childList?: boolean; + + /** + * Whether to observe attribute mutations. + */ + attributes?: boolean; + + /** + * Whether to observe character data mutations. + */ + characterData?: boolean; + + /** + * Whether to observe mutations on any descendant in addition to those on the target. + */ + subtree?: boolean; + + /** + * Whether to record the previous value of attributes. + */ + attributeOldValue?: boolean; + + /** + * Whether to record the previous value of character data nodes. + */ + characterDataOldValue?: boolean; +} + +export type MutationCallback = (records: MutationRecord[], observer: MutationObserver) => void; + +/** + * 3.3.1. Interface MutationObserver + * + * A MutationObserver object can be used to observe mutations to the tree of nodes. + */ +export default class MutationObserver { + /** + * The NotifyList instance is shared between all MutationObserver objects. It holds references to all + * MutationObserver instances that have collected records, and is responsible for invoking their callbacks when + * control returns to the event loop (using setImmediate or setTimeout). + */ + static _notifyList = new NotifyList(); + + /** + * The function that will be called when control returns to the event loop, if there are any queued records. The + * function is passed the MutationRecords and the observer instance that collected them. + */ + public _callback: MutationCallback; + + /** + * The list of nodes on which this observer is a RegisteredObserver's observer. + */ + public _nodes: Node[] = []; + + /** + * The list of MutationRecord objects collected so far. + */ + public _recordQueue: MutationRecord[] = []; + + /** + * Tracks transient registered observers created for this observer, to simplify their removal. + */ + public _transients: RegisteredObserver[] = []; + + /** + * Constructs a MutationObserver object and sets its callback to callback. The callback is invoked with a list of + * MutationRecord objects as first argument and the constructed MutationObserver object as second argument. It is + * invoked after nodes registered with the observe() method, are mutated. + * + * @param callback Function called after mutations have been observed. + */ + constructor(callback: MutationCallback) { + expectArity(arguments, 1); + callback = asObject(callback, Function); + + // create a new MutationObserver object with callback set to callback + this._callback = callback; + + // append it to the unit of related similar-origin browsing contexts' list of MutationObserver objects + // (for efficiency, this implementation only tracks MutationObserver objects that have records queued) + } + + /** + * Instructs the user agent to observe a given target (a node) and report any mutations based on the criteria given + * by options (an object). + * + * NOTE: Adding an observer to an element is just like addEventListener, if you observe the element multiple times + * it does not make a difference. Meaning if you observe element twice, the observe callback does not fire twice, + * nor will you have to run disconnect() twice. In other words, once an element is observed, observing it again with + * the same will do nothing. However if the callback object is different it will of course add another observer to + * it. + * + * @param target Node (or root of subtree) to observe + * @param options Determines which types of mutations to observe + */ + observe(target: Node, options: MutationObserverInit) { + expectArity(arguments, 2); + target = asObject(target, Node); + + // Defaults from IDL + options.childList = !!options.childList; + options.subtree = !!options.subtree; + + // 1. If either options’ attributeOldValue or attributeFilter is present and options’ attributes is omitted, set + // options’ attributes to true. + if (options.attributeOldValue !== undefined && options.attributes === undefined) { + options.attributes = true; + } + + // 2. If options’ characterDataOldValue is present and options’ characterData is omitted, set options’ + // characterData to true. + if (options.characterDataOldValue !== undefined && options.characterData === undefined) { + options.characterData = true; + } + // 3. If none of options’ childList, attributes, and characterData is true, throw a TypeError. + if (!(options.childList || options.attributes || options.characterData)) { + throw new TypeError( + 'The options object must set at least one of "attributes", "characterData", or "childList" to true.' + ); + } + + // 4. If options’ attributeOldValue is true and options’ attributes is false, throw a TypeError. + if (options.attributeOldValue && !options.attributes) { + throw new TypeError( + 'The options object may only set "attributeOldValue" to true when "attributes" is true or not present.' + ); + } + + // 5. If options’ attributeFilter is present and options’ attributes is false, throw a TypeError. + // (attributeFilter not yet implemented) + + // 6. If options’ characterDataOldValue is true and options’ characterData is false, throw a TypeError. + if (options.characterDataOldValue && !options.characterData) { + throw new TypeError( + 'The options object may only set "characterDataOldValue" to true when "characterData" is true or not ' + + 'present.' + ); + } + + // 7. For each registered observer registered in target’s list of registered observers whose observer is the + // context object: + // 7.1. Remove all transient registered observers whose source is registered. + // 7.2. Replace registered’s options with options. + // 8. Otherwise, add a new registered observer to target’s list of registered observers with the context object + // as the observer and options as the options, and add target to context object’s list of nodes on which it is + // registered. + target._registeredObservers.register(this, options); + } + + /** + * Stops the MutationObserver instance from receiving notifications of DOM mutations. Until the observe() method + * is used again, observer's callback will not be invoked. + */ + disconnect() { + // for each node node in context object’s list of nodes, remove any registered observer on node for which + // context object is the observer, + this._nodes.forEach(node => node._registeredObservers.removeForObserver(this)); + this._nodes.length = 0; + + // and also empty context object’s record queue. + this._recordQueue.length = 0; + } + + /** + * Empties the MutationObserver instance's record queue and returns what was in there. + * + * @return An Array of MutationRecord objects that were recorded. + */ + takeRecords(): MutationRecord[] { + // return a copy of the record queue + const recordQueue = this._recordQueue.concat(); + // and then empty the record queue + this._recordQueue.length = 0; + return recordQueue; + } +} diff --git a/src/mutation-observer/MutationRecord.ts b/src/mutation-observer/MutationRecord.ts new file mode 100644 index 0000000..b421263 --- /dev/null +++ b/src/mutation-observer/MutationRecord.ts @@ -0,0 +1,82 @@ +import Node from '../Node'; + +export interface MutationRecordInit { + name?: string; + namespace?: string | null; + oldValue?: string | null; + addedNodes?: Node[]; + removedNodes?: Node[]; + previousSibling?: Node | null; + nextSibling?: Node | null; +} + +/** + * 3.3.3. Interface MutationRecord + * + * A helper class which describes a specific mutation as it is observed by a MutationObserver. + */ +export default class MutationRecord { + /** + * Returns "attributes" if it was an attribute mutation. "characterData" if it was a mutation to a CharacterData + * node. And "childList" if it was a mutation to the tree of nodes. + */ + public type: string; + + /** + * Returns the node the mutation affected, depending on the type. For "attributes", it is the element whose + * attribute changed. For "characterData", it is the CharacterData node. For "childList", it is the node whose + * children changed. + */ + public target: Node; + + /** + * Children of target added in this mutation. + * + * (non-standard) According to the spec this should be a NodeList. This implementation uses an array. + */ + public addedNodes: Node[] = []; + + /** + * Children of target removed in this mutation. + * + * (non-standard) According to the spec this should be a NodeList. This implementation uses an array. + */ + public removedNodes: Node[] = []; + + /** + * The previous sibling of the added or removed nodes, or null otherwise. + */ + public previousSibling: Node | null = null; + + /** + * The next sibling Node of the added or removed nodes, or null otherwise. + */ + public nextSibling: Node | null = null; + + /** + * The local name of the changed attribute, or null otherwise. + */ + public attributeName: string | null = null; + + /** + * The namespace of the changed attribute, or null otherwise. + */ + public attributeNamespace: string | null = null; + + /** + * The return value depends on type. For "attributes", it is the value of the changed attribute before the change. + * For "characterData", it is the data of the changed node before the change. For "childList", it is null. + */ + public oldValue: string | null = null; + + /** + * (non-standard) Constructs a MutationRecord + * + * @param type The value for the type property + * @param target The value for the target property + */ + constructor(type: string, target: Node) { + this.type = type; + this.target = target; + } +} diff --git a/src/mutation-observer/NotifyList.ts b/src/mutation-observer/NotifyList.ts new file mode 100644 index 0000000..ea31005 --- /dev/null +++ b/src/mutation-observer/NotifyList.ts @@ -0,0 +1,97 @@ +import { MutationCallback, default as MutationObserver } from './MutationObserver'; +import MutationRecord from './MutationRecord'; +import { removeTransientRegisteredObserversForObserver } from './RegisteredObservers'; + +// Declare functions without having to bring in the entire DOM lib +declare function setImmediate(handler: (...args: any[]) => void): number +declare function setTimeout(handler: (...args: any[]) => void, timeout: number): number + +const hasSetImmediate = typeof setImmediate === 'function'; + +function queueCompoundMicrotask(callback: (...args: any[]) => void, thisArg: NotifyList, ...args: any[]): number { + // Branch taken is platform dependent and constant + /* istanbul ignore next */ + return (hasSetImmediate ? setImmediate : setTimeout)(() => { + callback.apply(thisArg, args); + }, 0); +} + +/** + * Tracks MutationObserver instances which have a non-empty record queue and schedules their callbacks to be called. + */ +export default class NotifyList { + private _notifyList: MutationObserver[] = []; + private _compoundMicrotaskQueued: number | null = null; + + /** + * Appends a given MutationRecord to the recordQueue of the given MutationObserver and schedules it for reporting. + * + * @param observer The observer for which to enqueue the record + * @param record The record to enqueue + */ + appendRecord(observer: MutationObserver, record: MutationRecord) { + observer._recordQueue.push(record); + this._notifyList.push(observer); + } + + /** + * To queue a mutation observer compound microtask, run these steps: + */ + public queueMutationObserverCompoundMicrotask() { + // 1. If mutation observer compound microtask queued flag is set, then return. + if (this._compoundMicrotaskQueued) { + return; + } + + // 2. Set mutation observer compound microtask queued flag. + // 3. Queue a compound microtask to notify mutation observers. + this._compoundMicrotaskQueued = queueCompoundMicrotask(() => { + this._notifyMutationObservers(); + }, this); + } + + /** + * To notify mutation observers, run these steps: + */ + private _notifyMutationObservers() { + // 1. Unset mutation observer compound microtask queued flag. + this._compoundMicrotaskQueued = null; + + // 2. Let notify list be a copy of unit of related similar-origin browsing contexts' list of MutationObserver + // objects. + const notifyList = this._notifyList.concat(); + // Clear the notify list - for efficiency this list only tracks observers that have a non-empty queue + this._notifyList.length = 0; + + // 3. Let signalList be a copy of unit of related similar-origin browsing contexts' signal slot list. + // 4. Empty unit of related similar-origin browsing contexts' signal slot list. + // (shadow dom not implemented) + + // 5. For each MutationObserver object mo in notify list, execute a compound microtask subtask to run these + // steps: [HTML] + notifyList.forEach(mo => { + queueCompoundMicrotask( + (mo: MutationObserver) => { + // 5.1. Let queue be a copy of mo’s record queue. + // 5.2. Empty mo’s record queue. + const queue = mo.takeRecords(); + + // 5.3. Remove all transient registered observers whose observer is mo. + removeTransientRegisteredObserversForObserver(mo); + + // 5.4. If queue is non-empty, invoke mo’s callback with a list of arguments consisting of queue and mo, + // and mo as the callback this value. If this throws an exception, report the exception. + if (queue.length) { + mo._callback(queue, mo); + } + }, + this, + mo + ); + }); + + // 6. For each slot slot in signalList, in order, fire an event named slotchange, with its bubbles + // attribute set to true, at slot. + // (shadow dom not implemented) + } +} diff --git a/src/mutation-observer/RegisteredObserver.ts b/src/mutation-observer/RegisteredObserver.ts new file mode 100644 index 0000000..1c87216 --- /dev/null +++ b/src/mutation-observer/RegisteredObserver.ts @@ -0,0 +1,116 @@ +import { MutationObserverInit, default as MutationObserver } from './MutationObserver'; +import { MutationRecordInit, default as MutationRecord } from './MutationRecord'; +import Node from '../Node'; + +/** + * A registered observer consists of an observer (a MutationObserver object) and options (a MutationObserverInit + * dictionary). A transient registered observer is a specific type of registered observer that has a source which is a + * registered observer. + * + * Transient registered observers are used to track mutations within a given node’s descendants after node has been + * removed so they do not get lost when subtree is set to true on node’s parent. + */ +export default class RegisteredObserver { + /** + * The observer that is registered. + */ + public observer: MutationObserver; + + /** + * The Node that is being observed by the given observer. + */ + public node: Node; + + /** + * The options for the registered observer. + */ + public options: MutationObserverInit; + + /** + * A transient observer is an observer that has a source which is an observer. + */ + public source: RegisteredObserver | null = null; + + /** + * @param observer The observer being registered + * @param node The node being observed + * @param options Options for the registration + * @param source If not null, creates a transient registered observer for the given registered observer + */ + constructor(observer: MutationObserver, node: Node, options: MutationObserverInit, source?: RegisteredObserver) { + this.observer = observer; + this.node = node; + this.options = options; + this.source = source || null; + if (source) { + observer._transients.push(this); + } + } + + /** + * Adds the given mutationRecord to the NotifyList of the registered MutationObserver. It only adds the record + * when it's type isn't blocked by one of the flags of this registered MutationObserver options (formally the + * MutationObserverInit object). + * + * @param type The type of mutation record to queue + * @param target The target node + * @param data The data for the mutation record + * @param interestedObservers Array of mutation observer objects to append to + * @param pairedStrings Paired strings for the mutation observer objects + */ + public collectInterestedObservers( + type: string, + target: Node, + data: MutationRecordInit, + interestedObservers: MutationObserver[], + pairedStrings: (string | null | undefined)[] + ) { + // (continued from RegisteredObservers#queueMutationRecord) + + // 3.1. If none of the following are true + // node is not target and options’ subtree is false + if (this.node !== target && !this.options.subtree) { + return; + } + + // type is "attributes" and options’ attributes is not true + if (type === 'attributes' && !this.options.attributes) { + return; + } + + // type is "attributes", options’ attributeFilter is present, and options’ attributeFilter does not contain name + // or namespace is non-null + // (attributeFilter not implemented) + + // type is "characterData" and options’ characterData is not true + if (type === 'characterData' && !this.options.characterData) { + return; + } + + // type is "childList" and options’ childList is false + if (type === 'childList' && !this.options.childList) { + return; + } + + // then: + + // 3.1.1. If registered observer’s observer is not in interested observers, append registered observer’s + // observer to interested observers. + let index = interestedObservers.indexOf(this.observer); + if (index < 0) { + index = interestedObservers.length; + interestedObservers.push(this.observer); + pairedStrings.push(undefined); + } + + // 3.1.2. If either type is "attributes" and options’ attributeOldValue is true, or type is "characterData" and + // options’ characterDataOldValue is true, set the paired string of registered observer’s observer in interested + // observers to oldValue. + if ( + (type === 'attributes' && this.options.attributeOldValue) || + (type === 'characterData' && this.options.characterDataOldValue) + ) { + pairedStrings[index] = data.oldValue; + } + } +} diff --git a/src/mutation-observer/RegisteredObservers.ts b/src/mutation-observer/RegisteredObservers.ts new file mode 100644 index 0000000..99c1c4b --- /dev/null +++ b/src/mutation-observer/RegisteredObservers.ts @@ -0,0 +1,178 @@ +import { MutationObserverInit, default as MutationObserver } from './MutationObserver'; +import { MutationRecordInit } from './MutationRecord'; +import RegisteredObserver from './RegisteredObserver'; +import Node from '../Node'; + +/** + * Each node has an associated list of registered observers. + */ +export default class RegisteredObservers { + /** + * The node for which this RegisteredObservers lists registered MutationObserver objects. + */ + private _node: Node; + + private _registeredObservers: RegisteredObserver[] = []; + + /** + * @param node Node for which this instance holds RegisteredObserver instances. + */ + constructor(node: Node) { + this._node = node; + } + + /** + * Registers a given MutationObserver with the given options. + * + * @param observer Observer to create a registration for + * @param options Options for the registration + */ + public register(observer: MutationObserver, options: MutationObserverInit) { + // (continuing from MutationObserver#observe) + // 7. For each registered observer registered in target’s list of registered observers whose observer is the + // context object: + const registeredObservers = this._registeredObservers; + let hasRegisteredObserverForObserver = false; + registeredObservers.forEach(registered => { + if (registered.observer !== observer) { + return; + } + + hasRegisteredObserverForObserver = true; + + // 7.1. Remove all transient registered observers whose source is registered. + removeTransientRegisteredObserversForSource(registered); + + // 7.2. Replace registered’s options with options. + registered.options = options; + }); + + // 8. Otherwise, add a new registered observer to target’s list of registered observers with the context object + // as the observer and options as the options, and add target to context object’s list of nodes on which it is + // registered. + if (!hasRegisteredObserverForObserver) { + this._registeredObservers.push(new RegisteredObserver(observer, this._node, options)); + // No registered observer for this observer at the current node means that node can't exist in the + // observer's list of nodes either. + observer._nodes.push(this._node); + } + } + + /** + * Removes the given transient registered observer. + * + * Transient registered observers never have a corresponding entry in the observer's list of nodes. They are + * guaranteed to be present in the array, as MutationObserver#_transients and + * RegisteredObservers#_registeredObservers are kept in sync. + * + * @param transientRegisteredObserver The registered observer to remove + */ + public removeTransientRegisteredObserver(transientRegisteredObserver: RegisteredObserver): void { + this._registeredObservers.splice(this._registeredObservers.indexOf(transientRegisteredObserver), 1); + } + + /** + * Remove any registered observer on the associated node for which observer is the observer. + * + * As this only occurs for all nodes at once, it is the caller's responsibility to remove the associated node from + * the observer's list of nodes. + * + * @param observer Observer for which to remove the registration + */ + public removeForObserver(observer: MutationObserver): void { + // Filter the array in-place + let write = 0; + for (let read = 0, l = this._registeredObservers.length; read < l; ++read) { + const registered = this._registeredObservers[read]; + if (registered.observer === observer) { + continue; + } + + if (read !== write) { + this._registeredObservers[write] = registered; + } + ++write; + } + this._registeredObservers.length = write; + } + + /** + * Determines interested observers for the given record. + * + * @param type The type of mutation record to queue + * @param target The target node + * @param data The data for the mutation record + * @param interestedObservers Array of mutation observer objects to append to + * @param pairedStrings Paired strings for the mutation observer objects + */ + public collectInterestedObservers( + type: string, + target: Node, + data: MutationRecordInit, + interestedObservers: MutationObserver[], + pairedStrings: (string | null | undefined)[] + ) { + // (continuing from queueMutationRecord) + // 3. ...and then for each registered observer (with registered observer’s options as options) in node’s list of + // registered observers: + this._registeredObservers.forEach(registeredObserver => { + registeredObserver.collectInterestedObservers(type, target, data, interestedObservers, pairedStrings); + }); + } + + /** + * Append transient registered observers for any registered observers whose options' subtree is true. + * + * @param node Node to append the transient registered observers to + */ + public appendTransientRegisteredObservers(node: Node): void { + this._registeredObservers.forEach(registeredObserver => { + if (registeredObserver.options.subtree) { + node._registeredObservers.registerTransient(registeredObserver); + } + }); + } + + /** + * Appends a transient registered observer for the given registered observer. + * + * @param source The source registered observer + */ + public registerTransient(source: RegisteredObserver): void { + this._registeredObservers.push(new RegisteredObserver(source.observer, this._node, source.options, source)); + // Note that node is not added to the transient observer's observer's list of nodes. + } +} + +/** + * Removes all transient registered observers whose observer is observer. + * + * @param observer The mutation observer object to remove transient registered observers for + */ +export function removeTransientRegisteredObserversForObserver(observer: MutationObserver): void { + observer._transients.forEach(transientRegisteredObserver => { + transientRegisteredObserver.node._registeredObservers.removeTransientRegisteredObserver( + transientRegisteredObserver + ); + }); + observer._transients.length = 0; +} + +/** + * Removes all transient registered observer whose source is source. + * + * @param source The registered observer to remove transient registered observers for + */ +export function removeTransientRegisteredObserversForSource(source: RegisteredObserver): void { + for (let i = source.observer._transients.length - 1; i >= 0; --i) { + const transientRegisteredObserver = source.observer._transients[i]; + if (transientRegisteredObserver.source !== source) { + return; + } + + transientRegisteredObserver.node._registeredObservers.removeTransientRegisteredObserver( + transientRegisteredObserver + ); + source.observer._transients.splice(i, 1); + } +} diff --git a/src/mutation-observer/queueMutationRecord.ts b/src/mutation-observer/queueMutationRecord.ts new file mode 100644 index 0000000..2cc5ebf --- /dev/null +++ b/src/mutation-observer/queueMutationRecord.ts @@ -0,0 +1,86 @@ +import MutationObserver from './MutationObserver'; +import { MutationRecordInit, default as MutationRecord } from './MutationRecord'; +import Node from '../Node'; + +/** + * 3.3.2. Queuing a mutation record + * + * To queue a mutation record of type for target with one or more of (depends on type) name name, namespace namespace, + * oldValue oldValue, addedNodes addedNodes, removedNodes removedNodes, previousSibling previousSibling, and nextSibling + * nextSibling, run these steps: + * + * @param type The type of mutation record to queue + * @param target The target node + * @param data The data for the mutation record + */ +export default function queueMutationRecord(type: string, target: Node, data: MutationRecordInit) { + // 1. Let interested observers be an initially empty set of MutationObserver objects optionally paired with a + // string. + const interestedObservers: MutationObserver[] = []; + const pairedStrings: (string | null | undefined)[] = []; + + // 2. Let nodes be the inclusive ancestors of target. + // 3. For each node in nodes, and then for each registered observer (with registered observer’s options as options) + // in node’s list of registered observers: + for (let node: Node | null = target; node; node = node.parentNode) { + // 3.1. If none of the following are true + // node is not target and options’ subtree is false + // type is "attributes" and options’ attributes is not true + // type is "attributes", options’ attributeFilter is present, and options’ attributeFilter does not contain name + // or namespace is non-null + // type is "characterData" and options’ characterData is not true + // type is "childList" and options’ childList is false + // then: + // 3.1.1. If registered observer’s observer is not in interested observers, append registered observer’s + // observer to interested observers. + // 3.1.2. If either type is "attributes" and options’ attributeOldValue is true, or type is "characterData" and + // options’ characterDataOldValue is true, set the paired string of registered observer’s observer in interested + // observers to oldValue. + node._registeredObservers.collectInterestedObservers(type, target, data, interestedObservers, pairedStrings); + } + + // 4. For each observer in interested observers: + interestedObservers.forEach((observer, index) => { + // 4.1. Let record be a new MutationRecord object with its type set to type and target set to target. + const record = new MutationRecord(type, target); + + // 4.2. If name and namespace are given, set record’s attributeName to name, and record’s attributeNamespace to + // namespace. + if (data.name !== undefined && data.namespace !== undefined) { + record.attributeName = data.name; + record.attributeNamespace = data.namespace; + } + + // 4.3. If addedNodes is given, set record’s addedNodes to addedNodes. + if (data.addedNodes !== undefined) { + record.addedNodes = data.addedNodes; + } + + // 4.4. If removedNodes is given, set record’s removedNodes to removedNodes, + if (data.removedNodes !== undefined) { + record.removedNodes = data.removedNodes; + } + + // 4.5. If previousSibling is given, set record’s previousSibling to previousSibling. + if (data.previousSibling !== undefined) { + record.previousSibling = data.previousSibling; + } + + // 4.6. If nextSibling is given, set record’s nextSibling to nextSibling. + if (data.nextSibling !== undefined) { + record.nextSibling = data.nextSibling; + } + + // 4.7. If observer has a paired string, set record’s oldValue to observer’s paired string. + const pairedString = pairedStrings[index]; + if (pairedString !== undefined) { + record.oldValue = pairedString; + } + + // 4.8. Append record to observer’s record queue. + MutationObserver._notifyList.appendRecord(observer, record); + }); + + // 5. Queue a mutation observer compound microtask. + MutationObserver._notifyList.queueMutationObserverCompoundMicrotask(); +} diff --git a/src/mutations/MutationObserver.ts b/src/mutations/MutationObserver.ts deleted file mode 100644 index d89ef10..0000000 --- a/src/mutations/MutationObserver.ts +++ /dev/null @@ -1,120 +0,0 @@ -import MutationRecord from './MutationRecord'; -import NotifyList from './NotifyList'; -import Node from '../Node'; - -export interface MutationObserverInit { - /** - * Whether to observe childList mutations. - */ - childList?: boolean; - - /** - * Whether to observe attribute mutations. - */ - attributes?: boolean; - - /** - * Whether to observe character data mutations. - */ - characterData?: boolean; - - /** - * (non-standard) whether to observe user data mutations. - */ - userData?: boolean; - - /** - * Whether to observe mutations on any descendant in addition to those on the target. - */ - subtree?: boolean; - - /** - * Whether to record the previous value of attributes. - */ - attributeOldValue?: boolean; - - /** - * Whether to record the previous value of character data nodes. - */ - characterDataOldValue?: boolean; -} - -export type MutationObserverCallback = (records: MutationRecord[], observer: MutationObserver) => void; - -/** - * A MutationObserver object can be used to observe mutations to the tree of nodes. - */ -export default class MutationObserver { - - /** - * (internal) The function that will be called on each DOM mutation. The observer will call this function with two - * arguments. The first is an array of objects, each of type MutationRecord. The second is this - * MutationObserver instance. - */ - public _callback: MutationObserverCallback; - - /** - * (internal) List of records collected so far. - */ - public _recordQueue: MutationRecord[] = []; - - /** - * (internal) A list of Node objects for which this MutationObserver is a registered observer. - */ - public _targets: Node[] = []; - - /** - * (internal) The NotifyList instance that is shared between all MutationObserver objects. Each observer queues - * its MutationRecord object on this list with a reference to itself. The NotifyList is then responsible for - * periodically reporting of these records to the observers. - */ - static _notifyList = new NotifyList(); - - /** - * @param callback Function called after mutations have been observed. - */ - constructor (callback: MutationObserverCallback) { - this._callback = callback; - } - - /** - * Registers the MutationObserver instance to receive notifications of DOM mutations on the specified node. - * - * NOTE: Adding an observer to an element is just like addEventListener, if you observe the element multiple - * times it does not make a difference. Meaning if you observe element twice, the observe callback does not fire - * twice, nor will you have to run disconnect() twice. In other words, once an element is observed, observing it - * again with the same will do nothing. However if the callback object is different it will of course add - * another observer to it. - * - * @param target Node (or root of subtree) to observe - * @param options Determines which types of mutations to observe - * @param isTransient (non-standard) Adds a transient registered observer if set to true - */ - observe (target: Node, options: MutationObserverInit, isTransient: boolean = false) { - target._registeredObservers.register(this, options, isTransient); - } - - /** - * Stops the MutationObserver instance from receiving notifications of DOM mutations. Until the observe() method - * is used again, observer's callback will not be invoked. - */ - disconnect () { - // Disconnect from each target - this._targets.forEach(target => target._registeredObservers.removeObserver(this)); - this._targets.length = 0; - - // Empty the record queue - this._recordQueue.length = 0; - } - - /** - * Empties the MutationObserver instance's record queue and returns what was in there. - * - * @return An Array of MutationRecord objects that were recorded. - */ - takeRecords (): MutationRecord[] { - const recordQueue = this._recordQueue; - this._recordQueue = []; - return recordQueue; - } -} diff --git a/src/mutations/MutationRecord.ts b/src/mutations/MutationRecord.ts deleted file mode 100644 index 93404e6..0000000 --- a/src/mutations/MutationRecord.ts +++ /dev/null @@ -1,54 +0,0 @@ -import Node from '../Node'; - -export type MutationRecordType = 'attributes' | 'characterData' | 'childList' | 'userData'; - -/** - * A helper class which describes a specific mutation as it is observed by a MutationObserver. - */ - export default class MutationRecord { - /** - * The type of MutationRecord - */ - public type: MutationRecordType; - - /** - * The node on or under which the mutation took place. - */ - public target: Node; - - /** - * Children of target added in this mutation. - */ - public addedNodes: Node[] = []; - - /** - * Children of target removed in this mutation. - */ - public removedNodes: Node[] = []; - - /** - * The previous sibling Node of the added or removed nodes if there were any. - */ - public previousSibling: Node | null = null; - - /** - * The next sibling Node of the added or removed nodes if there were any. - */ - public nextSibling: Node | null = null; - - /** - * The name of the changed attribute if there was any. - */ - public attributeName: string | null = null; - - /** - * Depending on the type: for "attributes", it is the value of the changed attribute before the change; - * for "characterData", it is the data of the changed node before the change; for "childList", it is null. - */ - public oldValue: any | null = null; - - constructor (type: MutationRecordType, target: Node) { - this.type = type; - this.target = target; - } -} diff --git a/src/mutations/NotifyList.ts b/src/mutations/NotifyList.ts deleted file mode 100644 index 1fa27a4..0000000 --- a/src/mutations/NotifyList.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { MutationObserverCallback, default as MutationObserver } from './MutationObserver'; -import MutationRecord from './MutationRecord'; - -const hasSetImmediate = (typeof setImmediate === 'function'); - -function schedule (callback: MutationObserverCallback, thisArg: NotifyList, ...args: any[]): number { - return (hasSetImmediate ? setImmediate : setTimeout)(() => { - callback.apply(thisArg, args); - }, 0); -} - -function removeTransientRegisteredObserversForObserver (observer: MutationObserver) { - // Remove all transient registered observers for this observer - // Process in reverse order, as the targets array may change during traversal - for (var i = observer._targets.length - 1; i >= 0; --i) { - observer._targets[i]._registeredObservers.removeTransients(observer); - } -} - -/** - * A helper class which is responsible for scheduling the queued MutationRecord objects for reporting by their - * observer. Reporting means the callback of the observer (a MutationObserver object) gets called with the - * relevant MutationRecord objects. - */ -export default class NotifyList { - private _notifyList: MutationObserver[] = []; - private _scheduled: number | null = null; - private _callbacks: MutationObserverCallback[] = []; - - /** - * Adds a given MutationRecord to the recordQueue of the given MutationObserver and schedules it for reporting. - * - * @param observer The observer for which to enqueue the record - * @param record The record to enqueue - */ - queueRecord (observer: MutationObserver, record: MutationRecord) { - // Only queue the same record once per observer - if (observer._recordQueue[observer._recordQueue.length - 1] === record) { - return; - } - - observer._recordQueue.push(record); - this._notifyList.push(observer); - this._scheduleInvoke(); - } - - /** - * Takes all the records from all the observers currently on this list and clears the current list. - */ - clear () { - this._notifyList.forEach(observer => observer.takeRecords()); - this._notifyList.length = 0; - } - - /** - * An internal helper method which is used to start the scheduled invocation of the callback from each of the - * observers on the current list, i.e. to report the MutationRecords. - */ - private _scheduleInvoke () { - if (this._scheduled) { - return; - } - - this._scheduled = schedule(() => { - this._scheduled = null; - this._invokeMutationObservers(); - }, this); - } - - /** - * An internal helper method which is used to invoke the callback from each of the observers on the current - * list, i.e. to report the MutationRecords. - */ - private _invokeMutationObservers () { - // Process notify list - let numCallbacks = 0; - this._notifyList.forEach(observer => { - const queue = observer.takeRecords(); - if (!queue.length) { - removeTransientRegisteredObserversForObserver(observer); - return; - } - - // Observer has records, schedule its callback - ++numCallbacks; - schedule((queue, observer) => { - try { - // According to the spec, transient registered observers for observer - // should be removed just before its callback is called. - removeTransientRegisteredObserversForObserver(observer); - observer._callback.call(null, queue, observer); - } - finally { - --numCallbacks; - if (!numCallbacks) { - // Callbacks may have queued additional mutations, check again later - this._scheduleInvoke(); - } - } - }, this, queue, observer); - }); - - this._notifyList.length = 0; - } -} diff --git a/src/mutations/RegisteredObserver.ts b/src/mutations/RegisteredObserver.ts deleted file mode 100644 index da93c34..0000000 --- a/src/mutations/RegisteredObserver.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { MutationObserverInit, default as MutationObserver } from './MutationObserver'; -import MutationRecord from './MutationRecord'; -import Node from '../Node'; - -/** - * This is an internal helper class that is used to work with a MutationObserver. - * - * Each node has an associated list of registered observers. A registered observer consists of an observer - * (a MutationObserver object) and options (a MutationObserverInit dictionary). A transient registered observer - * is a specific type of registered observer that has a source which is a registered observer. - */ -export default class RegisteredObserver { - /** - * The observer that is registered. - */ - public observer: MutationObserver; - - /** - * The Node that is being observed by the given observer. - */ - public target: Node; - - /** - * An options object (formally a MutationObserverInit object, but just a plain js object in Slimdom) which - * specifies which DOM mutations should be reported. TODO: add options property docs. - */ - public options: MutationObserverInit; - - /** - * A transient observer is an observer that has a source which is an observer. TODO: clarify the "source" - * keyword in this context. - */ - public isTransient: boolean; - - /** - * @param observer The observer being registered - * @param target The node being observed - * @param options Options for the registration - * @param isTransient Whether the registration is automatically removed when control returns to the event loop - */ - constructor (observer: MutationObserver, target: Node, options: MutationObserverInit, isTransient: boolean) { - this.observer = observer; - this.target = target; - this.options = options; - this.isTransient = isTransient; - } - - /** - * Adds the given mutationRecord to the NotifyList of the registered MutationObserver. It only adds the record - * when it's type isn't blocked by one of the flags of this registered MutationObserver options (formally the - * MutationObserverInit object). - * - * @param mutationRecord The record to enqueue - */ - public queueRecord (mutationRecord: MutationRecord) { - const options = this.options; - - // Only trigger ancestors if they are listening for subtree mutations - if (mutationRecord.target !== this.target && !options.subtree) { - return; - } - - // Ignore attribute modifications if we're not listening for them - if (!options.attributes && mutationRecord.type === 'attributes') { - return; - } - - // TODO: implement attribute filter? - - // Ignore user data modifications if we're not listening for them - if (!options.userData && mutationRecord.type === 'userData') { - return; - } - - // Ignore character data modifications if we're not listening for them - if (!options.characterData && mutationRecord.type === 'characterData') { - return; - } - - // Ignore child list modifications if we're not listening for them - if (!options.childList && mutationRecord.type === 'childList') { - return; - } - - // Queue the record - // TODO: we should probably make a copy here according to the options, but who cares about extra info? - MutationObserver._notifyList.queueRecord(this.observer, mutationRecord); - } -} diff --git a/src/mutations/RegisteredObservers.ts b/src/mutations/RegisteredObservers.ts deleted file mode 100644 index 3bcd2cf..0000000 --- a/src/mutations/RegisteredObservers.ts +++ /dev/null @@ -1,133 +0,0 @@ -import MutationObserver from './MutationObserver'; -import MutationRecord from './MutationRecord'; -import RegisteredObserver from './RegisteredObserver'; -import Node from '../Node'; - -/** - * This is an internal helper class that is used to work with a MutationObserver. - * - * Each node has an associated list of registered observers. A registered observer consists of an observer - * (a MutationObserver object) and options (a MutationObserverInit dictionary). A transient registered observer - * is a specific type of registered observer that has a source which is a registered observer. - */ -export default class RegisteredObservers { - /** - * The node for which this RegisteredObservers lists registered MutationObserver objects. - */ - - private _target: Node; - - private _registeredObservers: RegisteredObserver[] = []; - - /** - * @param target Node for which this instance holds RegisteredObserver instances. - */ - constructor (target: Node) { - this._target = target; - } - - /** - * Registers a given MutationObserver with the given options. - * - * @param observer Observer to create a registration for - * @param options Options for the registration - * @param isTransient Whether the registration is automatically removed when control returns to the event loop - */ - public register (observer: MutationObserver, options: MutationObserverInit, isTransient: boolean) { - // Ensure our node is in the observer's list of targets - if (observer._targets.indexOf(this._target) < 0) { - observer._targets.push(this._target); - } - - if (!isTransient) { - // Replace options for existing registered observer, if any - for (var i = 0, l = this._registeredObservers.length; i < l; ++i) { - var registeredObserver = this._registeredObservers[i]; - if (registeredObserver.observer !== observer) { - continue; - } - - if (registeredObserver.isTransient) { - continue; - } - - registeredObserver.options = options; - return; - } - } - - this._registeredObservers.push(new RegisteredObserver(observer, this._target, options, isTransient)); - } - - /** - * Creates transient registrations for all subtree observers on an ancestor of our target when target nodes are - * removed from under that ancestor. - * - * @param registeredObserversForAncestor Registrations for an ancestor of our target - */ - public appendTransientsForAncestor (registeredObserversForAncestor: RegisteredObservers) { - registeredObserversForAncestor._registeredObservers.forEach(ancestorRegisteredObserver => { - // Only append transients for subtree observers - if (!ancestorRegisteredObserver.options.subtree) { - return; - } - - this.register(ancestorRegisteredObserver.observer, ancestorRegisteredObserver.options, true); - }); - }; - - /** - * @param observer Observer for which to remove the registration - * @param transientsOnly Whether to remove only transient registrations - * - * @return Whether any non-transient registrations were not removed because transientsOnly was set to true - */ - public removeObserver (observer: MutationObserver, transientsOnly: boolean = false): boolean { - // Remove all registered observers for this observer - let write = 0; - let hasMore = false; - for (let read = 0, l = this._registeredObservers.length; read < l; ++read) { - const registeredObserver = this._registeredObservers[read]; - if (registeredObserver.observer === observer) { - if (!transientsOnly || registeredObserver.isTransient) { - continue; - } - // Record the fact a non-transient registered observer was skipped - if (!registeredObserver.isTransient) { - hasMore = true; - } - } - - if (read !== write) { - this._registeredObservers[write] = registeredObserver; - } - ++write; - } - this._registeredObservers.length = write; - - return hasMore; - } - - /** - * @param observer Observer to remove any transient registrations for - */ - public removeTransients (observer: MutationObserver) { - const hasNonTransients = this.removeObserver(observer, true); - if (!hasNonTransients) { - // Remove target from observer - var targetIndex = observer._targets.indexOf(this._target); - if (targetIndex >= 0) { - observer._targets.splice(targetIndex, 1); - } - } - } - - /** - * Queues a given MutationRecord on each registered MutationObserver in this list of registered observers. - * - * @param mutationRecord Record to enqueue - */ - public queueRecord (mutationRecord: MutationRecord) { - this._registeredObservers.forEach(registeredObserver => registeredObserver.queueRecord(mutationRecord)); - } -} diff --git a/src/mutations/queueMutationRecord.ts b/src/mutations/queueMutationRecord.ts deleted file mode 100644 index edbfc94..0000000 --- a/src/mutations/queueMutationRecord.ts +++ /dev/null @@ -1,14 +0,0 @@ -import MutationRecord from './MutationRecord'; -import Node from '../Node'; - -/** - * Queues mutation on all target nodes, and on all target nodes of all its ancestors. - * - * @param mutationRecord The record to enqueue - */ -export default function queueMutationRecord (mutationRecord: MutationRecord) { - // Check all inclusive ancestors of the target for registered observers - for (let node: Node | null = mutationRecord.target; node; node = node.parentNode) { - node._registeredObservers.queueRecord(mutationRecord); - } -} diff --git a/src/selections/Range.ts b/src/selections/Range.ts deleted file mode 100644 index b7411d5..0000000 --- a/src/selections/Range.ts +++ /dev/null @@ -1,242 +0,0 @@ -import Document from '../Document'; -import Node from '../Node'; -import { commonAncestor, comparePoints, getNodeIndex } from '../util'; - -export default class Range { - /** - * The node at which this range starts. - */ - public startContainer: Node | null; - - /** - * The offset in the startContainer at which this range starts. - */ - public startOffset: number; - - /** - * The node at which this range ends. - */ - public endContainer: Node | null; - - /** - * The offset in the endContainer at which this range ends. - */ - public endOffset: number; - - /** - * A range is collapsed if its start and end positions are identical. - */ - public collapsed: boolean; - - /** - * The closest node that is a parent of both start and end positions. - */ - public commonAncestorContainer: Node | null; - - /** - * A detached range should no longer be used. - */ - private _isDetached: boolean; - - /** - * Do not use directly! Use Document#createRange to create an instance. - * - * @param document Document in which to create the Range - */ - constructor (document: Document) { - this.startContainer = document; - this.startOffset = 0; - this.endContainer = document; - this.endOffset = 0; - - this.collapsed = true; - this.commonAncestorContainer = document; - - this._isDetached = false; - - // Start tracking the range - document._ranges.push(this); - } - - static START_TO_START = 0; - static START_TO_END = 1; - static END_TO_END = 2; - static END_TO_START = 3; - - /** - * Disposes the range and removes it from it's document. - */ - public detach () { - // Stop tracking the range - const startContainer = this.startContainer as Node; - const document = startContainer instanceof Document ? startContainer : startContainer.ownerDocument as Document; - const rangeIndex = document._ranges.indexOf(this); - document._ranges.splice(rangeIndex, 1); - - // Clear properties - this.startContainer = null; - this.startOffset = 0; - this.endContainer = null; - this.endOffset = 0; - this.collapsed = true; - this.commonAncestorContainer = null; - this._isDetached = true; - } - - /** - * Helper used to update the range when start and/or end has changed - */ - private _pointsChanged () { - this.commonAncestorContainer = commonAncestor(this.startContainer as Node, this.endContainer as Node); - this.collapsed = (this.startContainer == this.endContainer && this.startOffset == this.endOffset); - } - - /** - * Sets the start position of a range to a given node and a given offset inside that node. - * - * @param node Container for the position - * @param offset Index of the child or character before which to place the position - */ - public setStart (node: Node, offset: number) { - this.startContainer = node; - this.startOffset = offset; - - // If start is after end, move end to start - if (comparePoints(this.startContainer as Node, this.startOffset, this.endContainer as Node, this.endOffset) as number > 0) { - this.setEnd(node, offset); - } - - this._pointsChanged(); - } - - /** - * Sets the end position of a range to a given node and a given offset inside that node. - * - * @param node Container for the position - * @param offset Index of the child or character before which to place the position - */ - public setEnd (node: Node, offset: number) { - this.endContainer = node; - this.endOffset = offset; - - // If end is before start, move start to end - if (comparePoints(this.startContainer as Node, this.startOffset, this.endContainer as Node, this.endOffset) as number > 0) { - this.setStart(node, offset); - } - - this._pointsChanged(); - } - - /** - * Sets the start position of this Range relative to another Node. - * - * @param referenceNode Node before which to place the position - */ - public setStartBefore (referenceNode: Node) { - this.setStart(referenceNode.parentNode as Node, getNodeIndex(referenceNode)); - } - - /** - * Sets the start position of this Range relative to another Node. - * - * @param referenceNode Node after which to place the position - */ - public setStartAfter (referenceNode: Node) { - this.setStart(referenceNode.parentNode as Node, getNodeIndex(referenceNode) + 1); - } - - /** - * Sets the end position of this Range relative to another Node. - * - * @param referenceNode Node before which to place the position - */ - public setEndBefore (referenceNode: Node) { - this.setEnd(referenceNode.parentNode as Node, getNodeIndex(referenceNode)); - } - - /** - * Sets the end position of this Range relative to another Node. - * - * @param referenceNode Node after which to place the position - */ - public setEndAfter (referenceNode: Node) { - this.setEnd(referenceNode.parentNode as Node, getNodeIndex(referenceNode) + 1); - } - - /** - * Sets the Range to contain the Node and its contents. - * - * @param referenceNode Node to place the range around - */ - public selectNode (referenceNode: Node) { - this.setStartBefore(referenceNode); - this.setEndAfter(referenceNode); - } - - /** - * Sets the Range to contain the contents of a Node. - * - * @param referenceNode Node to place the range within - */ - public selectNodeContents (referenceNode: Node) { - this.setStart(referenceNode, 0); - this.setEnd(referenceNode, referenceNode.childNodes.length); - } - - /** - * Collapses the Range to one of its boundary points. - * - * @param toStart Whether to collapse to the start rather than the end position - */ - public collapse (toStart: boolean = false) { - if (toStart) { - this.setEnd(this.startContainer as Node, this.startOffset); - } - else { - this.setStart(this.endContainer as Node, this.endOffset); - } - } - - /** - * Create a new range with the same boundary points. - * - * @return Copy of the current range - */ - public cloneRange (): Range { - const startContainer = this.startContainer as Node; - const document = startContainer instanceof Document ? startContainer : startContainer.ownerDocument as Document; - const newRange = document.createRange(); - newRange.setStart(this.startContainer as Node, this.startOffset); - newRange.setEnd(this.endContainer as Node, this.endOffset); - - return newRange; - } - - /** - * Compares a boundary of the current range with a boundary of the specified range. - * - * @param comparisonType One of the constants exposed on the Range constructor determining the comparison to make - * @param range Range against which to compare the current instance - * - * @return Either negative, zero or positive, depending on the relative positions of the points being compared - */ - public compareBoundaryPoints (comparisonType: number, range: Range): number | undefined { - switch (comparisonType) { - case Range.START_TO_START: - return comparePoints(this.startContainer as Node, this.startOffset, range.startContainer as Node, range.startOffset); - case Range.START_TO_END: - return comparePoints(this.startContainer as Node, this.startOffset, range.endContainer as Node, range.endOffset); - case Range.END_TO_END: - return comparePoints(this.endContainer as Node, this.endOffset, range.endContainer as Node, range.endOffset); - case Range.END_TO_START: - return comparePoints(this.endContainer as Node, this.endOffset, range.startContainer as Node, range.startOffset); - } - - return undefined; - } -} - -(Range.prototype as any).START_TO_START = 0; -(Range.prototype as any).START_TO_END = 1; -(Range.prototype as any).END_TO_END = 2; -(Range.prototype as any).END_TO_START = 3; diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 34464eb..0000000 --- a/src/util.ts +++ /dev/null @@ -1,106 +0,0 @@ -import Node from './Node'; - -/** - * Get all inclusive ancestors of the given node. - * - * @param node Node to collect ancestors for - * - * @return All inclusive ancestors, ordered from root down to node - */ -export function parents (node: Node | null): Node[] { - const nodes = []; - while (node) { - nodes.unshift(node); - node = node.parentNode; - } - return nodes; -} - -/** - * Returns the index of the given node in its parent's childNodes. - * Used as an offset, this represents the position just before the given node. - * - * @param node Node to determine the index of - * - * @return The index among node's siblings - */ -export function getNodeIndex (node: Node): number { - return (node.parentNode as Node).childNodes.indexOf(node); -} - -/** - * Returns the first common ancestor of the two nodes. - * - * @param node1 First node - * @param node2 Second node - * - * @return Common ancestor of node1 and node2, or null if the nodes are in different trees - */ -export function commonAncestor (node1: Node, node2: Node): Node | null { - if (node1 === node2) { - return node1; - } - - const parents1 = parents(node1); - const parents2 = parents(node2); - let parent1 = parents1[0]; - let parent2 = parents2[0]; - - if (parent1 !== parent2) { - return null; - } - - for (let i = 1, l = Math.min(parents1.length, parents2.length); i < l; i++) { - // Let the commonAncestor be one step behind - const commonAncestor = parent1; - parent1 = parents1[i]; - parent2 = parents2[i]; - - if (!parent1 || !parent2 || parent1 !== parent2) { - return commonAncestor; - } - } - - // The common Ancestor is the node itself - return parent1; -} - -/** - * Compares two positions within the document. - * - * @param node1 Container of first position - * @param offset1 Offset of first position - * @param node2 Container of second position - * @param offset2 Offset of second position - * - * @return Negative, 0 or positive, depending on the relative ordering of the given positions, or undefined if the - * containers are in different trees - */ -export function comparePoints (node1: Node, offset1: number, node2: Node, offset2: number): number | undefined { - if (node1 !== node2) { - const parents1 = parents(node1); - const parents2 = parents(node2); - // This should not be called on nodes from different trees - if (parents1[0] !== parents2[0]) { - return undefined; - } - - // Skip common parents - while (parents1[0] && parents2[0] && parents1[0] === parents2[0]) { - parents1.shift(); - parents2.shift(); - } - - // Compute offsets at the level under the last common parent, - // we add 0.5 to indicate a position inside the parent rather than before or after - if (parents1.length) { - offset1 = getNodeIndex(parents1[0]) + 0.5; - } - if (parents2.length) { - offset2 = getNodeIndex(parents2[0]) + 0.5; - } - } - - // Compare positions at this level - return offset1 - offset2; -} diff --git a/src/util/NodeType.ts b/src/util/NodeType.ts new file mode 100644 index 0000000..0cc5c36 --- /dev/null +++ b/src/util/NodeType.ts @@ -0,0 +1,28 @@ +import Node from '../Node'; + +export const enum NodeType { + ELEMENT_NODE = 1, + ATTRIBUTE_NODE = 2, + TEXT_NODE = 3, + CDATA_SECTION_NODE = 4, + ENTITY_REFERENCE_NODE = 5, // historical + ENTITY_NODE = 6, // historical + PROCESSING_INSTRUCTION_NODE = 7, + COMMENT_NODE = 8, + DOCUMENT_NODE = 9, + DOCUMENT_TYPE_NODE = 10, + DOCUMENT_FRAGMENT_NODE = 11, + NOTATION_NODE = 12 // historical +} + +/** + * Checks whether the given node's nodeType is one of the specified values + * + * @param node The node to test + * @param types Possible nodeTypes for node + * + * @return Whether node.nodeType is one of the specified values + */ +export function isNodeOfType(node: Node, ...types: NodeType[]): boolean { + return types.some(t => node.nodeType === t); +} diff --git a/src/util/attrMutations.ts b/src/util/attrMutations.ts new file mode 100644 index 0000000..aacb385 --- /dev/null +++ b/src/util/attrMutations.ts @@ -0,0 +1,129 @@ +import Attr from '../Attr'; +import Element from '../Element'; +import queueMutationRecord from '../mutation-observer/queueMutationRecord'; + +/** + * To change an attribute attribute from an element element to value, run these steps: + * + * @param attribute The attribute to change + * @param element The element that has the attribute + * @param value The new value for the attribute + */ +export function changeAttribute(attribute: Attr, element: Element, value: string): void { + // 1. Queue a mutation record of "attributes" for element with name attribute’s local name, namespace attribute’s + // namespace, and oldValue attribute’s value. + queueMutationRecord('attributes', element, { + name: attribute.localName, + namespace: attribute.namespaceURI, + oldValue: attribute.value + }); + + // 2. If element is custom, then enqueue a custom element callback reaction with element, callback name + // "attributeChangedCallback", and an argument list containing attribute’s local name, attribute’s value, value, and + // attribute’s namespace. + // (custom elements not implemented) + + // 3. Run the attribute change steps with element, attribute’s local name, attribute’s value, value, and attribute’s + // namespace. + // (attribute change steps not implemented) + + // 4. Set attribute’s value to value. + (attribute as any)._value = value; +} + +/** + * To append an attribute attribute to an element element, run these steps: + * + * @param attribute The attribute to append + * @param element The element to append attribute to + */ +export function appendAttribute(attribute: Attr, element: Element): void { + // 1. Queue a mutation record of "attributes" for element with name attribute’s local name, namespace attribute’s + // namespace, and oldValue null. + queueMutationRecord('attributes', element, { + name: attribute.localName, + namespace: attribute.namespaceURI, + oldValue: null + }); + + // 2. If element is custom, then enqueue a custom element callback reaction with element, callback name + // "attributeChangedCallback", and an argument list containing attribute’s local name, null, attribute’s value, and + // attribute’s namespace. + // (custom elements not implemented) + + // 3. Run the attribute change steps with element, attribute’s local name, null, attribute’s value, and attribute’s + // namespace. + // (attribute change steps not implemented) + + // 4. Append attribute to element’s attribute list. + element.attributes.push(attribute); + + // 5. Set attribute’s element to element. + attribute.ownerElement = element; +} + +/** + * To remove an attribute attribute from an element element, run these steps: + * + * @param attribute The attribute to remove + * @param element The element to remove attribute from + */ +export function removeAttribute(attribute: Attr, element: Element): void { + // 1. Queue a mutation record of "attributes" for element with name attribute’s local name, namespace attribute’s + // namespace, and oldValue attribute’s value. + queueMutationRecord('attributes', element, { + name: attribute.localName, + namespace: attribute.namespaceURI, + oldValue: attribute.value + }); + + // 2. If element is custom, then enqueue a custom element callback reaction with element, callback name + // "attributeChangedCallback", and an argument list containing attribute’s local name, attribute’s value, null, and + // attribute’s namespace. + // (custom elements not implemented) + + // 3. Run the attribute change steps with element, attribute’s local name, attribute’s value, null, and attribute’s + // namespace. + // (attribute change steps not implemented) + + // 4. Remove attribute from element’s attribute list. + element.attributes.splice(element.attributes.indexOf(attribute), 1); + + // 5. Set attribute’s element to null. + attribute.ownerElement = null; +} + +/** + * To replace an attribute oldAttr by an attribute newAttr in an element element, run these steps: + * + * @param oldAttr The attribute to replace + * @param newAttr The attribute to replace oldAttr with + * @param element The element on which to replace the attribute + */ +export function replaceAttribute(oldAttr: Attr, newAttr: Attr, element: Element): void { + // 1. Queue a mutation record of "attributes" for element with name oldAttr’s local name, namespace oldAttr’s + // namespace, and oldValue oldAttr’s value. + queueMutationRecord('attributes', element, { + name: oldAttr.localName, + namespace: oldAttr.namespaceURI, + oldValue: oldAttr.value + }); + + // 2. If element is custom, then enqueue a custom element callback reaction with element, callback name + // "attributeChangedCallback", and an argument list containing oldAttr’s local name, oldAttr’s value, newAttr’s + // value, and oldAttr’s namespace. + // (custom elements not implemented) + + // 3. Run the attribute change steps with element, oldAttr’s local name, oldAttr’s value, newAttr’s value, and + // oldAttr’s namespace. + // (attribute change steps not implemented) + + // 4. Replace oldAttr by newAttr in element’s attribute list. + element.attributes.splice(element.attributes.indexOf(oldAttr), 1, newAttr); + + // 5. Set oldAttr’s element to null. + oldAttr.ownerElement = null; + + // 6. Set newAttr’s element to element. + newAttr.ownerElement = element; +} diff --git a/src/util/cloneNode.ts b/src/util/cloneNode.ts new file mode 100644 index 0000000..e132c9d --- /dev/null +++ b/src/util/cloneNode.ts @@ -0,0 +1,56 @@ +import Document from '../Document'; +import Node from '../Node'; + +import { isNodeOfType, NodeType } from './NodeType'; +import { getNodeDocument } from './treeHelpers'; + +// 3.4. Interface Node + +/** + * To clone a node, with an optional document and clone children flag, run these steps: + * + * @param node The node to clone + * @param cloneChildren Whether to also clone node's descendants + * @param document The document used to create the copy + */ +export default function cloneNode(node: Node, cloneChildren: boolean, document?: Document): Node { + // 1. If document is not given, let document be node’s node document. + if (!document) { + document = getNodeDocument(node); + } + + // 2. If node is an element, then: + // 2.1. Let copy be the result of creating an element, given document, node’s local name, node’s namespace, + // node’s namespace prefix, and the value of node’s is attribute if present (or null if not). The synchronous + // custom elements flag should be unset. + // 2.2. For each attribute in node’s attribute list: + // 2.2.1. Let copyAttribute be a clone of attribute. + // 2.2.2. Append copyAttribute to copy. + // 3. Otherwise, let copy be a node that implements the same interfaces as node, and fulfills these additional + // requirements, switching on node: + // Document: Set copy’s encoding, content type, URL, origin, type, and mode, to those of node. + // DocumentType: Set copy’s name, public ID, and system ID, to those of node. + // Attr: Set copy’s namespace, namespace prefix, local name, and value, to those of node. + // Text, Comment: Set copy’s data, to that of node. + // ProcessingInstruction: Set copy’s target and data to those of node. + // Any other node: — + // 4. Set copy’s node document and document to copy, if copy is a document, and set copy’s node document to document + // otherwise. + // (all handled by _copy method) + let copy = node._copy(document); + + // 5. Run any cloning steps defined for node in other applicable specifications and pass copy, node, document and the + // clone children flag if set, as parameters. + // (cloning steps not implemented) + + // 6. If the clone children flag is set, clone all the children of node and append them to copy, with document as + // specified and the clone children flag being set. + if (cloneChildren) { + for (let child = node.firstChild; child; child = child.nextSibling) { + copy.appendChild(cloneNode(child, true, document)); + } + } + + // 7. Return copy. + return copy; +} diff --git a/src/util/createElementNS.ts b/src/util/createElementNS.ts new file mode 100644 index 0000000..c39ea96 --- /dev/null +++ b/src/util/createElementNS.ts @@ -0,0 +1,33 @@ +import Document from '../Document'; +import { createElement, default as Element } from '../Element'; +import { validateAndExtract } from './namespaceHelpers'; + +// 3.5. Interface Document + +/** + * The internal createElementNS steps, given document, namespace, qualifiedName, and options, are as follows: + * + * @param document The node document for the new element + * @param namespace The namespace for the new element + * @param qualifiedName The qualified name for the new element + * + * @return The new element + */ +export default function createElementNS(document: Document, namespace: string | null, qualifiedName: string): Element { + // 1. Let namespace, prefix, and localName be the result of passing namespace and qualifiedName to validate and + // extract. + const { namespace: validatedNamespace, prefix, localName } = validateAndExtract(namespace, qualifiedName); + + // 2. Let is be the value of is member of options, or null if no such member exists. + // (custom elements not implemented) + + // 3. Let element be the result of creating an element given document, localName, namespace, prefix, is, and + // with the synchronous custom elements flag set. + const element = createElement(document, localName, validatedNamespace, prefix); + + // 4. If is is non-null, then set an attribute value for element using "is" and is. + // (custom elements not implemented) + + // 5. Return element. + return element; +} diff --git a/src/util/errorHelpers.ts b/src/util/errorHelpers.ts new file mode 100644 index 0000000..113f6ba --- /dev/null +++ b/src/util/errorHelpers.ts @@ -0,0 +1,55 @@ +export function expectArity(args: IArguments, minArity: number): void { + // According to WebIDL overload resolution semantics, only a lower bound applies to the number of arguments provided + if (args.length < minArity) { + throw new TypeError(`Function should be called with at least ${minArity} arguments`); + } +} + +export function expectObject(value: T, Constructor: Function): void { + if (!(value instanceof Constructor)) { + throw new TypeError(`Value should be an instance of ${Constructor.name}`); + } +} + +function createDOMException(name: string, code: number, message: string): Error { + const err = new Error(`${name}: ${message}`); + err.name = name; + (err as any).code = code; + return err; +} + +export function throwHierarchyRequestError(message: string): never { + throw createDOMException('HierarchyRequestError', 3, message); +} + +export function throwIndexSizeError(message: string): never { + throw createDOMException('IndexSizeError', 1, message); +} + +export function throwInUseAttributeError(message: string): never { + throw createDOMException('InUseAttributeError', 10, message); +} + +export function throwInvalidCharacterError(message: string): never { + throw createDOMException('InvalidCharacterError', 5, message); +} + +export function throwInvalidNodeTypeError(message: string): never { + throw createDOMException('InvalidNodeTypeError', 24, message); +} + +export function throwNamespaceError(message: string): never { + throw createDOMException('NamespaceError', 14, message); +} + +export function throwNotFoundError(message: string): never { + throw createDOMException('NotFoundError', 8, message); +} + +export function throwNotSupportedError(message: string): never { + throw createDOMException('NotSupportedError', 9, message); +} + +export function throwWrongDocumentError(message: string): never { + throw createDOMException('WrongDocumentError', 4, message); +} diff --git a/src/util/mutationAlgorithms.ts b/src/util/mutationAlgorithms.ts new file mode 100644 index 0000000..cb956a0 --- /dev/null +++ b/src/util/mutationAlgorithms.ts @@ -0,0 +1,571 @@ +import { throwHierarchyRequestError, throwNotFoundError } from './errorHelpers'; +import { NodeType, isNodeOfType } from './NodeType'; +import { determineLengthOfNode, getNodeDocument, getNodeIndex, forEachInclusiveDescendant } from './treeHelpers'; +import { insertIntoChildren, removeFromChildren } from './treeMutations'; +import Document from '../Document'; +import DocumentFragment from '../DocumentFragment'; +import Element from '../Element'; +import Node from '../Node'; +import { ranges } from '../Range'; +import queueMutationRecord from '../mutation-observer/queueMutationRecord'; + +// 3.2.3. Mutation algorithms + +/** + * To ensure pre-insertion validity of a node into a parent before a child, run these steps: + */ +function ensurePreInsertionValidity(node: Node, parent: Node, child: Node | null): void { + // 1. If parent is not a Document, DocumentFragment, or Element node, throw a HierarchyRequestError. + if (!isNodeOfType(parent, NodeType.DOCUMENT_NODE, NodeType.DOCUMENT_FRAGMENT_NODE, NodeType.ELEMENT_NODE)) { + throwHierarchyRequestError('parent must be a Document, DocumentFragment or Element node'); + } + + // 2. If node is a host-including inclusive ancestor of parent, throw a HierarchyRequestError. + if (node.contains(parent)) { + throwHierarchyRequestError('node must not be an inclusive ancestor of parent'); + } + + // 3. If child is not null and its parent is not parent, then throw a NotFoundError. + if (child && child.parentNode !== parent) { + throwNotFoundError('child is not a child of parent'); + } + + // 4. If node is not a DocumentFragment, DocumentType, Element, Text, ProcessingInstruction, or Comment node, throw + // a HierarchyRequestError. + if ( + !isNodeOfType( + node, + NodeType.DOCUMENT_FRAGMENT_NODE, + NodeType.DOCUMENT_TYPE_NODE, + NodeType.ELEMENT_NODE, + NodeType.TEXT_NODE, + NodeType.CDATA_SECTION_NODE, + NodeType.PROCESSING_INSTRUCTION_NODE, + NodeType.COMMENT_NODE + ) + ) { + throwHierarchyRequestError( + 'node must be a DocumentFragment, DocumentType, Element, Text, ProcessingInstruction or Comment node' + ); + } + + // 5. If either node is a Text node and parent is a document, or node is a doctype and parent is not a document, + // throw a HierarchyRequestError. + if (isNodeOfType(node, NodeType.TEXT_NODE) && isNodeOfType(parent, NodeType.DOCUMENT_NODE)) { + throwHierarchyRequestError('can not insert a Text node under a Document'); + } + if (isNodeOfType(node, NodeType.DOCUMENT_TYPE_NODE) && !isNodeOfType(parent, NodeType.DOCUMENT_NODE)) { + throwHierarchyRequestError('can only insert a DocumentType node under a Document'); + } + + // 6. If parent is a document, and any of the statements below, switched on node, are true, throw a + // HierarchyRequestError. + if (isNodeOfType(parent, NodeType.DOCUMENT_NODE)) { + const parentDocument = parent as Document; + switch (node.nodeType) { + // DocumentFragment node + case NodeType.DOCUMENT_FRAGMENT_NODE: + // If node has more than one element child or has a Text node child. + const fragment = node as DocumentFragment; + if (fragment.firstElementChild !== fragment.lastElementChild) { + throwHierarchyRequestError('can not insert more than one element under a Document'); + } + if (Array.from(fragment.childNodes).some(child => isNodeOfType(child, NodeType.TEXT_NODE))) { + throwHierarchyRequestError('can not insert a Text node under a Document'); + } + // Otherwise, if node has one element child and either parent has an element child, child is a doctype, + // or child is not null and a doctype is following child. + if ( + fragment.firstElementChild && + (parentDocument.documentElement || + (child && isNodeOfType(child, NodeType.DOCUMENT_TYPE_NODE)) || + (child && parentDocument.doctype && getNodeIndex(child) < getNodeIndex(parentDocument.doctype))) + ) { + throwHierarchyRequestError('Document should contain at most one doctype, followed by at most one element'); + } + break; + + // element + case NodeType.ELEMENT_NODE: + // parent has an element child, child is a doctype, or child is not null and a doctype is following + // child. + if ( + parentDocument.documentElement || + (child && isNodeOfType(child, NodeType.DOCUMENT_TYPE_NODE)) || + (child && parentDocument.doctype && getNodeIndex(child) < getNodeIndex(parentDocument.doctype)) + ) { + throwHierarchyRequestError('Document should contain at most one doctype, followed by at most one element'); + } + break; + + // doctype + case NodeType.DOCUMENT_TYPE_NODE: + // parent has a doctype child, child is non-null and an element is preceding child, or child is null and + // parent has an element child. + if ( + parentDocument.doctype || + (child && + parentDocument.documentElement && + getNodeIndex(parentDocument.documentElement) < getNodeIndex(child)) || + (!child && parentDocument.documentElement) + ) { + throwHierarchyRequestError('Document should contain at most one doctype, followed by at most one element'); + } + break; + } + } +} + +/** + * To pre-insert a node into a parent before a child, run these steps: + * + * @param node Node to pre-insert + * @param parent Parent to insert under + * @param child Child to insert before, or null to insert at the end of parent + * + * @return The inserted node + */ +export function preInsertNode(node: Node, parent: Node, child: Node | null): Node { + // 1. Ensure pre-insertion validity of node into parent before child. + ensurePreInsertionValidity(node, parent, child); + + // 2. Let reference child be child. + let referenceChild = child; + + // 3. If reference child is node, set it to node’s next sibling. + if (referenceChild === node) { + referenceChild = node.nextSibling; + } + + // 4. Adopt node into parent’s node document. + adoptNode(node, getNodeDocument(parent)); + + // 5. Insert node into parent before reference child. + insertNode(node, parent, referenceChild); + + // 6. Return node. + return node; +} + +/** + * To insert a node into a parent before a child, with an optional suppress observers flag, run these steps: + * + * @param node Node to insert + * @param parent Parent to insert under + * @param child Child to insert before, or null to insert at end of parent + * @param suppressObservers Whether to skip enqueueing a mutation record for this mutation + */ +export function insertNode(node: Node, parent: Node, child: Node | null, suppressObservers: boolean = false): void { + // 1. Let count be the number of children of node if it is a DocumentFragment node, and one otherwise. + const isDocumentFragment = isNodeOfType(node, NodeType.DOCUMENT_FRAGMENT_NODE); + const count = isDocumentFragment ? determineLengthOfNode(node) : 1; + + // 2. If child is non-null, then: + if (child !== null) { + const childIndex = getNodeIndex(child); + ranges.forEach(range => { + // 2.1. For each range whose start node is parent and start offset is greater than child’s index, increase + // its start offset by count. + if (range.startContainer === parent && range.startOffset > childIndex) { + range.startOffset += count; + } + + // 2.2. For each range whose end node is parent and end offset is greater than child’s index, increase its + // end offset by count. + if (range.endContainer === parent && range.endOffset > childIndex) { + range.endOffset += count; + } + }); + } + + // (see note at 7.) + const oldPreviousSibling = child === null ? parent.lastChild : child.previousSibling; + + // 3. Let nodes be node’s children if node is a DocumentFragment node, and a list containing solely node otherwise. + const nodes = isDocumentFragment ? Array.from(node.childNodes) : [node]; + + // 4. If node is a DocumentFragment node, remove its children with the suppress observers flag set. + if (isDocumentFragment) { + nodes.forEach(n => removeNode(n, node, true)); + } + + // 5. If node is a DocumentFragment node, queue a mutation record of "childList" for node with removedNodes nodes. + // This step intentionally does not pay attention to the suppress observers flag. + if (isDocumentFragment) { + queueMutationRecord('childList', node, { + removedNodes: nodes + }); + } + + // 6. For each node in nodes, in tree order: + nodes.forEach(node => { + // 6.1. If child is null, then append node to parent’s children. + // 6.2. Otherwise, insert node into parent’s children before child’s index. + insertIntoChildren(node, parent, child); + + // 6.3. If parent is a shadow host and node is a slotable, then assign a slot for node. + // 6.4. If parent is a slot whose assigned nodes is the empty list, then run signal a slot change for parent. + // 6.5. Run assign slotables for a tree with node’s tree and a set containing each inclusive descendant of node + // that is a slot. + // (shadow dom not implemented) + + // 6.6. For each shadow-including inclusive descendant inclusiveDescendant of node, in shadow-including tree + // order: + // 6.6.1. Run the insertion steps with inclusiveDescendant. + // (insertion steps not implemented) + + // 6.6.2. If inclusiveDescendant is connected, then: + // 6.6.2.1. If inclusiveDescendant is custom, then enqueue a custom element callback reaction with + // inclusiveDescendant, callback name "connectedCallback", and an empty argument list. + // 6.6.2.2. Otherwise, try to upgrade inclusiveDescendant. + // If this successfully upgrades inclusiveDescendant, its connectedCallback will be enqueued automatically + // during the upgrade an element algorithm. + // (custom elements not implemented) + }); + + // 7. If suppress observers flag is unset, queue a mutation record of "childList" for parent with addedNodes nodes, + // nextSibling child, and previousSibling child’s previous sibling or parent’s last child if child is null. + // Note: if implemented as stated in the spec, previous sibling would be determined after insertion, and would + // therefore always be the last of nodes. + if (!suppressObservers) { + queueMutationRecord('childList', parent, { + addedNodes: nodes, + nextSibling: child, + previousSibling: oldPreviousSibling + }); + } +} + +/** + * To append a node to a parent + * + * @param node Node to append + * @param parent Parent to append to + * + * @return The appended node + */ +export function appendNode(node: Node, parent: Node): Node { + // pre-insert node into parent before null. + return preInsertNode(node, parent, null); +} + +/** + * To replace a child with node within a parent, run these steps: + * + * @param child The child node to replace + * @param node The node to replace child with + * @param parent The parent to replace under + * + * @return The old child node + */ +export function replaceChildWithNode(child: Node, node: Node, parent: Node): Node { + // 1. If parent is not a Document, DocumentFragment, or Element node, throw a HierarchyRequestError. + if (!isNodeOfType(parent, NodeType.DOCUMENT_NODE, NodeType.DOCUMENT_FRAGMENT_NODE, NodeType.ELEMENT_NODE)) { + throwHierarchyRequestError('Can not replace under a non-parent node'); + } + + // 2. If node is a host-including inclusive ancestor of parent, throw a HierarchyRequestError. + if (node.contains(parent)) { + throwHierarchyRequestError('Can not insert a node under its own descendant'); + } + + // 3. If child’s parent is not parent, then throw a NotFoundError. + if (child.parentNode !== parent) { + throwNotFoundError('child is not a child of parent'); + } + + // 4. If node is not a DocumentFragment, DocumentType, Element, Text, ProcessingInstruction, or Comment node, throw + // a HierarchyRequestError. + if ( + !isNodeOfType( + node, + NodeType.DOCUMENT_FRAGMENT_NODE, + NodeType.DOCUMENT_TYPE_NODE, + NodeType.ELEMENT_NODE, + NodeType.TEXT_NODE, + NodeType.CDATA_SECTION_NODE, + NodeType.PROCESSING_INSTRUCTION_NODE, + NodeType.COMMENT_NODE + ) + ) { + throwHierarchyRequestError( + "Can not insert a node that isn't a DocumentFragment, DocumentType, Element, Text, " + + 'ProcessingInstruction or Comment' + ); + } + + // 5. If either node is a Text node and parent is a document, or node is a doctype and parent is not a document, + // throw a HierarchyRequestError. + if (isNodeOfType(node, NodeType.TEXT_NODE) && isNodeOfType(parent, NodeType.DOCUMENT_NODE)) { + throwHierarchyRequestError('can not insert a Text node under a Document'); + } + if (isNodeOfType(node, NodeType.DOCUMENT_TYPE_NODE) && !isNodeOfType(parent, NodeType.DOCUMENT_NODE)) { + throwHierarchyRequestError('can only insert a DocumentType node under a Document'); + } + + // 6. If parent is a document, and any of the statements below, switched on node, are true, throw a + // HierarchyRequestError. + if (isNodeOfType(parent, NodeType.DOCUMENT_NODE)) { + const parentDocument = parent as Document; + switch (node.nodeType) { + // DocumentFragment node + case NodeType.DOCUMENT_FRAGMENT_NODE: + // If node has more than one element child or has a Text node child. + const fragment = node as DocumentFragment; + if (fragment.firstElementChild !== fragment.lastElementChild) { + throwHierarchyRequestError('can not insert more than one element under a Document'); + } + if (Array.from(fragment.childNodes).some(child => isNodeOfType(child, NodeType.TEXT_NODE))) { + throwHierarchyRequestError('can not insert a Text node under a Document'); + } + // Otherwise, if node has one element child and either parent has an element child that is not child or + // a doctype is following child. + if ( + fragment.firstElementChild && + ((parentDocument.documentElement && parentDocument.documentElement !== child) || + (child && parentDocument.doctype && getNodeIndex(child) < getNodeIndex(parentDocument.doctype))) + ) { + throwHierarchyRequestError('Document should contain at most one doctype, followed by at most one element'); + } + break; + + // element + case NodeType.ELEMENT_NODE: + // parent has an element child that is not child or a doctype is following child. + if ( + (parentDocument.documentElement && parentDocument.documentElement !== child) || + (parentDocument.doctype && getNodeIndex(child) < getNodeIndex(parentDocument.doctype)) + ) { + throwHierarchyRequestError('Document should contain at most one doctype, followed by at most one element'); + } + break; + + // doctype + case NodeType.DOCUMENT_TYPE_NODE: + // parent has a doctype child that is not child, or an element is preceding child. + if ( + (parentDocument.doctype && parentDocument.doctype !== child) || + (parentDocument.documentElement && getNodeIndex(parentDocument.documentElement) < getNodeIndex(child)) + ) { + throwHierarchyRequestError('Document should contain at most one doctype, followed by at most one element'); + } + break; + } + // The above statements differ from the pre-insert algorithm. + } + + // 7. Let reference child be child’s next sibling. + let referenceChild = child.nextSibling; + + // 8. If reference child is node, set it to node’s next sibling. + if (referenceChild === node) { + referenceChild = node.nextSibling; + } + + // 9. Let previousSibling be child’s previous sibling. + const previousSibling = child.previousSibling; + + // 10. Adopt node into parent’s node document. + adoptNode(node, getNodeDocument(parent)); + + // 11. Let removedNodes be the empty list. + let removedNodes: Node[] = []; + + // 12. If child’s parent is not null, then: + if (child.parentNode !== null) { + // 12.1. Set removedNodes to a list solely containing child. + removedNodes.push(child); + + // 12.2. Remove child from its parent with the suppress observers flag set. + removeNode(child, child.parentNode, true); + } + // The above can only be false if child is node. + + // 13. Let nodes be node’s children if node is a DocumentFragment node, and a list containing solely node otherwise. + const nodes = isNodeOfType(node, NodeType.DOCUMENT_FRAGMENT_NODE) ? Array.from(node.childNodes) : [node]; + + // 14. Insert node into parent before reference child with the suppress observers flag set. + insertNode(node, parent, referenceChild, true); + + // 15. Queue a mutation record of "childList" for target parent with addedNodes nodes, removedNodes removedNodes, + // nextSibling reference child, and previousSibling previousSibling. + queueMutationRecord('childList', parent, { + addedNodes: nodes, + removedNodes: removedNodes, + nextSibling: referenceChild, + previousSibling: previousSibling + }); + + // 16. Return child. + return child; +} + +/** + * To pre-remove a child from a parent, run these steps: + * + * @param child Child node to remove + * @param parent Parent under which to remove child + * + * @return The removed child + */ +export function preRemoveChild(child: Node, parent: Node): Node { + // 1. If child’s parent is not parent, then throw a NotFoundError. + if (child.parentNode !== parent) { + throwNotFoundError('child is not a child of parent'); + } + + // 2. Remove child from parent. + removeNode(child, parent); + + // 3. Return child. + return child; +} + +/** + * To remove a node from a parent, with an optional suppress observers flag, run these steps: + * + * @param node Child to remove + * @param parent Parent to remove child from + * @param suppressObservers Whether to skip enqueueing a mutation record for this mutation + */ +export function removeNode(node: Node, parent: Node, suppressObservers: boolean = false): void { + // 1. Let index be node’s index. + const index = getNodeIndex(node); + + ranges.forEach(range => { + // 2. For each range whose start node is an inclusive descendant of node, set its start to (parent, index). + if (node.contains(range.startContainer)) { + range.startContainer = parent; + range.startOffset = index; + } + + // 3. For each range whose end node is an inclusive descendant of node, set its end to (parent, index). + if (node.contains(range.endContainer)) { + range.endContainer = parent; + range.endOffset = index; + } + + // 4. For each range whose start node is parent and start offset is greater than index, decrease its start + // offset by one. + if (range.startContainer === parent && range.startOffset > index) { + range.startOffset -= 1; + } + + // 5. For each range whose end node is parent and end offset is greater than index, decrease its end offset by + // one. + if (range.endContainer === parent && range.endOffset > index) { + range.endOffset -= 1; + } + }); + + // 6. For each NodeIterator object iterator whose root’s node document is node’s node document, run the NodeIterator + // pre-removing steps given node and iterator. + // (NodeIterator not implemented) + + // 7. Let oldPreviousSibling be node’s previous sibling. + const oldPreviousSibling = node.previousSibling; + + // 8. Let oldNextSibling be node’s next sibling. + const oldNextSibling = node.nextSibling; + + // 9. Remove node from its parent’s children. + removeFromChildren(node, parent); + + // 10. If node is assigned, then run assign slotables for node’s assigned slot. + // (shadow dom not implemented) + + // 11. If parent is a slot whose assigned nodes is the empty list, then run signal a slot change for parent. + // (shadow dom not implemented) + + // 12. If node has an inclusive descendant that is a slot, then: + // 12.1. Run assign slotables for a tree with parent’s tree. + // 12.2. Run assign slotables for a tree with node’s tree and a set containing each inclusive descendant of node + // that is a slot. + // (shadow dom not implemented) + + // 13. Run the removing steps with node and parent. + // (removing steps not implemented) + + // 14. If node is custom, then enqueue a custom element callback reaction with node, callback name + // "disconnectedCallback", and an empty argument list. + // It is intentional for now that custom elements do not get parent passed. This might change in the future if there + // is a need. + // (custom elements not implemented) + + // 15. For each shadow-including descendant descendant of node, in shadow-including tree order, then: + // 15.1. Run the removing steps with descendant. + // (shadow dom not implemented) + + // 15.2. If descendant is custom, then enqueue a custom element callback reaction with descendant, callback name + // "disconnectedCallback", and an empty argument list. + // (custom elements not implemented) + + // 16. For each inclusive ancestor inclusiveAncestor of parent, if inclusiveAncestor has any registered observers + // whose options' subtree is true, then for each such registered observer registered, append a transient registered + // observer whose observer and options are identical to those of registered and source which is registered to node’s + // list of registered observers. + for ( + let inclusiveAncestor: Node | null = parent; + inclusiveAncestor; + inclusiveAncestor = inclusiveAncestor.parentNode + ) { + inclusiveAncestor._registeredObservers.appendTransientRegisteredObservers(node); + } + + // 17. If suppress observers flag is unset, queue a mutation record of "childList" for parent with removedNodes a + // list solely containing node, nextSibling oldNextSibling, and previousSibling oldPreviousSibling. + if (!suppressObservers) { + queueMutationRecord('childList', parent, { + removedNodes: [node], + nextSibling: oldNextSibling, + previousSibling: oldPreviousSibling + }); + } +} + +/** + * 3.5. Interface Document + * + * To adopt a node into a document, run these steps: + * + * @param node Node to adopt + * @param document Document to adopt node into + */ +export function adoptNode(node: Node, document: Document): void { + // 1. Let oldDocument be node’s node document. + const oldDocument = getNodeDocument(node); + + // 2. If node’s parent is not null, remove node from its parent. + if (node.parentNode) { + removeNode(node, node.parentNode); + } + + // 3. If document is not oldDocument, then: + if (document === oldDocument) { + return; + } + + // 3.1. For each inclusiveDescendant in node’s shadow-including inclusive descendants: + forEachInclusiveDescendant(node, node => { + // 3.1.1. Set inclusiveDescendant’s node document to document. + // (calling code ensures that node is never a Document) + node.ownerDocument = document; + + // 3.1.2. If inclusiveDescendant is an element, then set the node document of each attribute in + // inclusiveDescendant’s attribute list to document. + if (isNodeOfType(node, NodeType.ELEMENT_NODE)) { + for (const attr of (node as Element).attributes) { + attr.ownerDocument = document; + } + } + }); + + // 3.2. For each inclusiveDescendant in node’s shadow-including inclusive descendants that is custom, enqueue a + // custom element callback reaction with inclusiveDescendant, callback name "adoptedCallback", and an argument list + // containing oldDocument and document. + // (custom element support has not been implemented) + + // 3.3. For each inclusiveDescendant in node’s shadow-including inclusive descendants, in shadow-including tree + // order, run the adopting steps with inclusiveDescendant and oldDocument. + // (adopting steps not implemented) +} diff --git a/src/util/namespaceHelpers.ts b/src/util/namespaceHelpers.ts new file mode 100644 index 0000000..11af483 --- /dev/null +++ b/src/util/namespaceHelpers.ts @@ -0,0 +1,210 @@ +import Element from '../Element'; +import Node from '../Node'; +import { throwInvalidCharacterError, throwNamespaceError } from './errorHelpers'; + +// 1.5. Namespaces + +const XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace'; +export const XMLNS_NAMESPACE = 'http://www.w3.org/2000/xmlns/'; + +/* +// NAME_REGEX_XML_1_0_FOURTH_EDITION generated using regenerate: +var regenerate = require("regenerate"); + +const productions = { + NameChar: "Letter | Digit | '.' | '-' | '_' | ':' | CombiningChar | Extender", + Letter: "BaseChar | Ideographic", + BaseChar: "[#x0041-#x005A] | [#x0061-#x007A] | [#x00C0-#x00D6] | [#x00D8-#x00F6] | [#x00F8-#x00FF] | [#x0100-#x0131] | [#x0134-#x013E] | [#x0141-#x0148] | [#x014A-#x017E] | [#x0180-#x01C3] | [#x01CD-#x01F0] | [#x01F4-#x01F5] | [#x01FA-#x0217] | [#x0250-#x02A8] | [#x02BB-#x02C1] | #x0386 | [#x0388-#x038A] | #x038C | [#x038E-#x03A1] | [#x03A3-#x03CE] | [#x03D0-#x03D6] | #x03DA | #x03DC | #x03DE | #x03E0 | [#x03E2-#x03F3] | [#x0401-#x040C] | [#x040E-#x044F] | [#x0451-#x045C] | [#x045E-#x0481] | [#x0490-#x04C4] | [#x04C7-#x04C8] | [#x04CB-#x04CC] | [#x04D0-#x04EB] | [#x04EE-#x04F5] | [#x04F8-#x04F9] | [#x0531-#x0556] | #x0559 | [#x0561-#x0586] | [#x05D0-#x05EA] | [#x05F0-#x05F2] | [#x0621-#x063A] | [#x0641-#x064A] | [#x0671-#x06B7] | [#x06BA-#x06BE] | [#x06C0-#x06CE] | [#x06D0-#x06D3] | #x06D5 | [#x06E5-#x06E6] | [#x0905-#x0939] | #x093D | [#x0958-#x0961] | [#x0985-#x098C] | [#x098F-#x0990] | [#x0993-#x09A8] | [#x09AA-#x09B0] | #x09B2 | [#x09B6-#x09B9] | [#x09DC-#x09DD] | [#x09DF-#x09E1] | [#x09F0-#x09F1] | [#x0A05-#x0A0A] | [#x0A0F-#x0A10] | [#x0A13-#x0A28] | [#x0A2A-#x0A30] | [#x0A32-#x0A33] | [#x0A35-#x0A36] | [#x0A38-#x0A39] | [#x0A59-#x0A5C] | #x0A5E | [#x0A72-#x0A74] | [#x0A85-#x0A8B] | #x0A8D | [#x0A8F-#x0A91] | [#x0A93-#x0AA8] | [#x0AAA-#x0AB0] | [#x0AB2-#x0AB3] | [#x0AB5-#x0AB9] | #x0ABD | #x0AE0 | [#x0B05-#x0B0C] | [#x0B0F-#x0B10] | [#x0B13-#x0B28] | [#x0B2A-#x0B30] | [#x0B32-#x0B33] | [#x0B36-#x0B39] | #x0B3D | [#x0B5C-#x0B5D] | [#x0B5F-#x0B61] | [#x0B85-#x0B8A] | [#x0B8E-#x0B90] | [#x0B92-#x0B95] | [#x0B99-#x0B9A] | #x0B9C | [#x0B9E-#x0B9F] | [#x0BA3-#x0BA4] | [#x0BA8-#x0BAA] | [#x0BAE-#x0BB5] | [#x0BB7-#x0BB9] | [#x0C05-#x0C0C] | [#x0C0E-#x0C10] | [#x0C12-#x0C28] | [#x0C2A-#x0C33] | [#x0C35-#x0C39] | [#x0C60-#x0C61] | [#x0C85-#x0C8C] | [#x0C8E-#x0C90] | [#x0C92-#x0CA8] | [#x0CAA-#x0CB3] | [#x0CB5-#x0CB9] | #x0CDE | [#x0CE0-#x0CE1] | [#x0D05-#x0D0C] | [#x0D0E-#x0D10] | [#x0D12-#x0D28] | [#x0D2A-#x0D39] | [#x0D60-#x0D61] | [#x0E01-#x0E2E] | #x0E30 | [#x0E32-#x0E33] | [#x0E40-#x0E45] | [#x0E81-#x0E82] | #x0E84 | [#x0E87-#x0E88] | #x0E8A | #x0E8D | [#x0E94-#x0E97] | [#x0E99-#x0E9F] | [#x0EA1-#x0EA3] | #x0EA5 | #x0EA7 | [#x0EAA-#x0EAB] | [#x0EAD-#x0EAE] | #x0EB0 | [#x0EB2-#x0EB3] | #x0EBD | [#x0EC0-#x0EC4] | [#x0F40-#x0F47] | [#x0F49-#x0F69] | [#x10A0-#x10C5] | [#x10D0-#x10F6] | #x1100 | [#x1102-#x1103] | [#x1105-#x1107] | #x1109 | [#x110B-#x110C] | [#x110E-#x1112] | #x113C | #x113E | #x1140 | #x114C | #x114E | #x1150 | [#x1154-#x1155] | #x1159 | [#x115F-#x1161] | #x1163 | #x1165 | #x1167 | #x1169 | [#x116D-#x116E] | [#x1172-#x1173] | #x1175 | #x119E | #x11A8 | #x11AB | [#x11AE-#x11AF] | [#x11B7-#x11B8] | #x11BA | [#x11BC-#x11C2] | #x11EB | #x11F0 | #x11F9 | [#x1E00-#x1E9B] | [#x1EA0-#x1EF9] | [#x1F00-#x1F15] | [#x1F18-#x1F1D] | [#x1F20-#x1F45] | [#x1F48-#x1F4D] | [#x1F50-#x1F57] | #x1F59 | #x1F5B | #x1F5D | [#x1F5F-#x1F7D] | [#x1F80-#x1FB4] | [#x1FB6-#x1FBC] | #x1FBE | [#x1FC2-#x1FC4] | [#x1FC6-#x1FCC] | [#x1FD0-#x1FD3] | [#x1FD6-#x1FDB] | [#x1FE0-#x1FEC] | [#x1FF2-#x1FF4] | [#x1FF6-#x1FFC] | #x2126 | [#x212A-#x212B] | #x212E | [#x2180-#x2182] | [#x3041-#x3094] | [#x30A1-#x30FA] | [#x3105-#x312C] | [#xAC00-#xD7A3]", + Ideographic: "[#x4E00-#x9FA5] | #x3007 | [#x3021-#x3029]", + CombiningChar: "[#x0300-#x0345] | [#x0360-#x0361] | [#x0483-#x0486] | [#x0591-#x05A1] | [#x05A3-#x05B9] | [#x05BB-#x05BD] | #x05BF | [#x05C1-#x05C2] | #x05C4 | [#x064B-#x0652] | #x0670 | [#x06D6-#x06DC] | [#x06DD-#x06DF] | [#x06E0-#x06E4] | [#x06E7-#x06E8] | [#x06EA-#x06ED] | [#x0901-#x0903] | #x093C | [#x093E-#x094C] | #x094D | [#x0951-#x0954] | [#x0962-#x0963] | [#x0981-#x0983] | #x09BC | #x09BE | #x09BF | [#x09C0-#x09C4] | [#x09C7-#x09C8] | [#x09CB-#x09CD] | #x09D7 | [#x09E2-#x09E3] | #x0A02 | #x0A3C | #x0A3E | #x0A3F | [#x0A40-#x0A42] | [#x0A47-#x0A48] | [#x0A4B-#x0A4D] | [#x0A70-#x0A71] | [#x0A81-#x0A83] | #x0ABC | [#x0ABE-#x0AC5] | [#x0AC7-#x0AC9] | [#x0ACB-#x0ACD] | [#x0B01-#x0B03] | #x0B3C | [#x0B3E-#x0B43] | [#x0B47-#x0B48] | [#x0B4B-#x0B4D] | [#x0B56-#x0B57] | [#x0B82-#x0B83] | [#x0BBE-#x0BC2] | [#x0BC6-#x0BC8] | [#x0BCA-#x0BCD] | #x0BD7 | [#x0C01-#x0C03] | [#x0C3E-#x0C44] | [#x0C46-#x0C48] | [#x0C4A-#x0C4D] | [#x0C55-#x0C56] | [#x0C82-#x0C83] | [#x0CBE-#x0CC4] | [#x0CC6-#x0CC8] | [#x0CCA-#x0CCD] | [#x0CD5-#x0CD6] | [#x0D02-#x0D03] | [#x0D3E-#x0D43] | [#x0D46-#x0D48] | [#x0D4A-#x0D4D] | #x0D57 | #x0E31 | [#x0E34-#x0E3A] | [#x0E47-#x0E4E] | #x0EB1 | [#x0EB4-#x0EB9] | [#x0EBB-#x0EBC] | [#x0EC8-#x0ECD] | [#x0F18-#x0F19] | #x0F35 | #x0F37 | #x0F39 | #x0F3E | #x0F3F | [#x0F71-#x0F84] | [#x0F86-#x0F8B] | [#x0F90-#x0F95] | #x0F97 | [#x0F99-#x0FAD] | [#x0FB1-#x0FB7] | #x0FB9 | [#x20D0-#x20DC] | #x20E1 | [#x302A-#x302F] | #x3099 | #x309A", + Digit: "[#x0030-#x0039] | [#x0660-#x0669] | [#x06F0-#x06F9] | [#x0966-#x096F] | [#x09E6-#x09EF] | [#x0A66-#x0A6F] | [#x0AE6-#x0AEF] | [#x0B66-#x0B6F] | [#x0BE7-#x0BEF] | [#x0C66-#x0C6F] | [#x0CE6-#x0CEF] | [#x0D66-#x0D6F] | [#x0E50-#x0E59] | [#x0ED0-#x0ED9] | [#x0F20-#x0F29]", + Extender: "#x00B7 | #x02D0 | #x02D1 | #x0387 | #x0640 | #x0E46 | #x0EC6 | #x3005 | [#x3031-#x3035] | [#x309D-#x309E] | [#x30FC-#x30FE]" +}; + +function createSetRegex (prod, set = regenerate()) { + return prod.split(' | ').reduce((set, part) => { + let m = part.match(/^\[#x([0-9A-F]+)-#x([0-9A-F]+)\]$/); + if (m) { + return set.addRange(parseInt(m[1], 16), parseInt(m[2], 16)); + } + m = part.match(/^#x([0-9A-F]+)$/); + if (m) { + return set.add(parseInt(m[1], 16)); + } + m = part.match(/^'(.)'$/); + if (m) { + return set.add(m[1]); + } + return createSetRegex(productions[part], set); + }, set); +} + +// Name ::= (Letter | '_' | ':') (NameChar)* +`^(?:${createRegex("Letter | '_' | ':'")})(?:${createRegex('NameChar')})*$`; +*/ +const NAME_REGEX_XML_1_0_FOURTH_EDITION = /^(?:[:A-Z_a-z\xC0-\xD6\xD8-\xF6\xF8-\u0131\u0134-\u013E\u0141-\u0148\u014A-\u017E\u0180-\u01C3\u01CD-\u01F0\u01F4\u01F5\u01FA-\u0217\u0250-\u02A8\u02BB-\u02C1\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03CE\u03D0-\u03D6\u03DA\u03DC\u03DE\u03E0\u03E2-\u03F3\u0401-\u040C\u040E-\u044F\u0451-\u045C\u045E-\u0481\u0490-\u04C4\u04C7\u04C8\u04CB\u04CC\u04D0-\u04EB\u04EE-\u04F5\u04F8\u04F9\u0531-\u0556\u0559\u0561-\u0586\u05D0-\u05EA\u05F0-\u05F2\u0621-\u063A\u0641-\u064A\u0671-\u06B7\u06BA-\u06BE\u06C0-\u06CE\u06D0-\u06D3\u06D5\u06E5\u06E6\u0905-\u0939\u093D\u0958-\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8B\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B36-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD\u0EAE\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0F40-\u0F47\u0F49-\u0F69\u10A0-\u10C5\u10D0-\u10F6\u1100\u1102\u1103\u1105-\u1107\u1109\u110B\u110C\u110E-\u1112\u113C\u113E\u1140\u114C\u114E\u1150\u1154\u1155\u1159\u115F-\u1161\u1163\u1165\u1167\u1169\u116D\u116E\u1172\u1173\u1175\u119E\u11A8\u11AB\u11AE\u11AF\u11B7\u11B8\u11BA\u11BC-\u11C2\u11EB\u11F0\u11F9\u1E00-\u1E9B\u1EA0-\u1EF9\u1F00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2126\u212A\u212B\u212E\u2180-\u2182\u3007\u3021-\u3029\u3041-\u3094\u30A1-\u30FA\u3105-\u312C\u4E00-\u9FA5\uAC00-\uD7A3])(?:[\-\.0-:A-Z_a-z\xB7\xC0-\xD6\xD8-\xF6\xF8-\u0131\u0134-\u013E\u0141-\u0148\u014A-\u017E\u0180-\u01C3\u01CD-\u01F0\u01F4\u01F5\u01FA-\u0217\u0250-\u02A8\u02BB-\u02C1\u02D0\u02D1\u0300-\u0345\u0360\u0361\u0386-\u038A\u038C\u038E-\u03A1\u03A3-\u03CE\u03D0-\u03D6\u03DA\u03DC\u03DE\u03E0\u03E2-\u03F3\u0401-\u040C\u040E-\u044F\u0451-\u045C\u045E-\u0481\u0483-\u0486\u0490-\u04C4\u04C7\u04C8\u04CB\u04CC\u04D0-\u04EB\u04EE-\u04F5\u04F8\u04F9\u0531-\u0556\u0559\u0561-\u0586\u0591-\u05A1\u05A3-\u05B9\u05BB-\u05BD\u05BF\u05C1\u05C2\u05C4\u05D0-\u05EA\u05F0-\u05F2\u0621-\u063A\u0640-\u0652\u0660-\u0669\u0670-\u06B7\u06BA-\u06BE\u06C0-\u06CE\u06D0-\u06D3\u06D5-\u06E8\u06EA-\u06ED\u06F0-\u06F9\u0901-\u0903\u0905-\u0939\u093C-\u094D\u0951-\u0954\u0958-\u0963\u0966-\u096F\u0981-\u0983\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09DC\u09DD\u09DF-\u09E3\u09E6-\u09F1\u0A02\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A59-\u0A5C\u0A5E\u0A66-\u0A74\u0A81-\u0A83\u0A85-\u0A8B\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABC-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE0\u0AE6-\u0AEF\u0B01-\u0B03\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B36-\u0B39\u0B3C-\u0B43\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B82\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0BE7-\u0BEF\u0C01-\u0C03\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C60\u0C61\u0C66-\u0C6F\u0C82\u0C83\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0D02\u0D03\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D3E-\u0D43\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D60\u0D61\u0D66-\u0D6F\u0E01-\u0E2E\u0E30-\u0E3A\u0E40-\u0E4E\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD\u0EAE\u0EB0-\u0EB9\u0EBB-\u0EBD\u0EC0-\u0EC4\u0EC6\u0EC8-\u0ECD\u0ED0-\u0ED9\u0F18\u0F19\u0F20-\u0F29\u0F35\u0F37\u0F39\u0F3E-\u0F47\u0F49-\u0F69\u0F71-\u0F84\u0F86-\u0F8B\u0F90-\u0F95\u0F97\u0F99-\u0FAD\u0FB1-\u0FB7\u0FB9\u10A0-\u10C5\u10D0-\u10F6\u1100\u1102\u1103\u1105-\u1107\u1109\u110B\u110C\u110E-\u1112\u113C\u113E\u1140\u114C\u114E\u1150\u1154\u1155\u1159\u115F-\u1161\u1163\u1165\u1167\u1169\u116D\u116E\u1172\u1173\u1175\u119E\u11A8\u11AB\u11AE\u11AF\u11B7\u11B8\u11BA\u11BC-\u11C2\u11EB\u11F0\u11F9\u1E00-\u1E9B\u1EA0-\u1EF9\u1F00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u20D0-\u20DC\u20E1\u2126\u212A\u212B\u212E\u2180-\u2182\u3005\u3007\u3021-\u302F\u3031-\u3035\u3041-\u3094\u3099\u309A\u309D\u309E\u30A1-\u30FA\u30FC-\u30FE\u3105-\u312C\u4E00-\u9FA5\uAC00-\uD7A3])*$/; + +/* +// NAME_REGEX_XML_1_0_FIFTH_EDITION generated using regenerate: +const regenerate = require('regenerate'); + +const NameStartChar = regenerate() + .add(':') + .addRange('A', 'Z') + .add('_') + .addRange('a', 'z') + .addRange(0xC0, 0xD6) + .addRange(0xD8, 0xF6) + .addRange(0xF8, 0x2FF) + .addRange(0x370, 0x37D) + .addRange(0x37F, 0x1FFF) + .addRange(0x200C, 0x200D) + .addRange(0x2070, 0x218F) + .addRange(0x2C00, 0x2FEF) + .addRange(0x3001, 0xD7FF) + .addRange(0xF900, 0xFDCF) + .addRange(0xFDF0, 0xFFFD) + .addRange(0x10000, 0xEFFFF); + +const NameChar = NameStartChar.clone() + .add('-') + .add('.') + .addRange('0', '9') + .add(0xB7) + .addRange(0x0300, 0x036F) + .addRange(0x203F, 0x2040); + +return `^(?:${NameStartChar.toString()})(?:${NameChar.toString()})*$`; +*/ +const NAME_REGEX_XML_1_0_FIFTH_EDITION = /^(?:[:A-Z_a-z\xC0-\xD6\xD8-\xF6\xF8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]|[\uD800-\uDB7F][\uDC00-\uDFFF])(?:[\-\.0-:A-Z_a-z\xB7\xC0-\xD6\xD8-\xF6\xF8-\u037D\u037F-\u1FFF\u200C\u200D\u203F\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]|[\uD800-\uDB7F][\uDC00-\uDFFF])*$/; + +/** + * Returns true if name matches the Name production. + * + * @param name The name to check + * + * @return true if name matches Name, otherwise false + */ +export function matchesNameProduction(name: string): boolean { + return NAME_REGEX_XML_1_0_FOURTH_EDITION.test(name); +} + +/** + * As we're already testing against Name, testing QName validity can be reduced to checking if the name contains at + * most a single colon which is not at the first or last position. + * + * @param name The name to check + * + * @return True if the name is a valid QName, provided it is also a valid Name, otherwise false + */ +function isValidQName(name: string): boolean { + const parts = name.split(':'); + if (parts.length > 2) { + return false; + } + if (parts.length === 1) { + return true; + } + // First part should not be empty, and the second part should be a valid name + return parts[0].length > 0 && matchesNameProduction(parts[1]); +} + +/** + * To validate a qualifiedName, + * + * @param qualifiedName Qualified name to validate + */ +export function validateQualifiedName(qualifiedName: string): void { + // throw an InvalidCharacterError if qualifiedName does not match the Name or QName production. + // (QName is basically (Name without ':') ':' (Name without ':'), so just check the position of the : + if (!isValidQName(qualifiedName) || !matchesNameProduction(qualifiedName)) { + throwInvalidCharacterError('The qualified name is not a valid Name or QName'); + } +} + +/** + * To validate and extract a namespace and qualifiedName, run these steps: + * + * @param namespace Namespace for the qualified name + * @param qualifiedName Qualified name to validate and extract the components of + * + * @return Namespace, prefix and localName + */ +export function validateAndExtract( + namespace: string | null, + qualifiedName: string +): { namespace: string | null; prefix: string | null; localName: string } { + // 1. If namespace is the empty string, set it to null. + if (namespace === '') { + namespace = null; + } + + // 2. Validate qualifiedName. + validateQualifiedName(qualifiedName); + + // 3. Let prefix be null. + let prefix: string | null = null; + + // 4. Let localName be qualifiedName. + let localName = qualifiedName; + + // 5. If qualifiedName contains a ":" (U+003E), then split the string on it and set prefix to the part before and + // localName to the part after. + const index = qualifiedName.indexOf(':'); + if (index >= 0) { + prefix = qualifiedName.substring(0, index); + localName = qualifiedName.substring(index + 1); + } + + // 6. If prefix is non-null and namespace is null, then throw a NamespaceError. + if (prefix !== null && namespace === null) { + throwNamespaceError('Qualified name with prefix can not have a null namespace'); + } + + // 7. If prefix is "xml" and namespace is not the XML namespace, then throw a NamespaceError. + if (prefix === 'xml' && namespace !== XML_NAMESPACE) { + throwNamespaceError('xml prefix can only be used for the XML namespace'); + } + + // 8. If either qualifiedName or prefix is "xmlns" and namespace is not the XMLNS namespace, then throw a NamespaceError. + if ((qualifiedName === 'xmlns' || prefix === 'xmlns') && namespace !== XMLNS_NAMESPACE) { + throwNamespaceError('xmlns prefix or qualifiedName must use the XMLNS namespace'); + } + + // 9. If namespace is the XMLNS namespace and neither qualifiedName nor prefix is "xmlns", then throw a NamespaceError. + if (namespace === XMLNS_NAMESPACE && qualifiedName !== 'xmlns' && prefix !== 'xmlns') { + throwNamespaceError('xmlns prefix or qualifiedName must be used for the XMLNS namespace'); + } + + // 10. Return namespace, prefix, and localName. + return { namespace, prefix, localName }; +} + +/** + * To locate a namespace prefix for an element using namespace, run these steps: + * + * @param element The element at which to start the lookup + * @param namespace Namespace for which to look up the prefix + * + * @return The prefix, or null if there isn't one + */ +export function locateNamespacePrefix(element: Element, namespace: string | null): string | null { + // 1. If element’s namespace is namespace and its namespace prefix is not null, then return its namespace prefix. + if (element.namespaceURI === namespace && element.prefix !== null) { + return element.prefix; + } + + // 2. If element has an attribute whose namespace prefix is "xmlns" and value is namespace, then return element’s first such attribute’s local name. + const attr = Array.from(element.attributes).find(attr => attr.prefix === 'xmlns' && attr.value === namespace); + if (attr) { + return attr.localName; + } + + // 3. If element’s parent element is not null, then return the result of running locate a namespace prefix on that element using namespace. + if (element.parentElement !== null) { + return locateNamespacePrefix(element.parentElement, namespace); + } + + // 4. Return null. + return null; +} diff --git a/src/util/treeHelpers.ts b/src/util/treeHelpers.ts new file mode 100644 index 0000000..eecc860 --- /dev/null +++ b/src/util/treeHelpers.ts @@ -0,0 +1,100 @@ +import CharacterData from '../CharacterData'; +import Document from '../Document'; +import Node from '../Node'; +import { NodeType, isNodeOfType } from './NodeType'; + +/** + * 3.2. Node Tree: to determine the length of a node, switch on node: + * + * @param node The node to determine the length of + * + * @return The length of the node + */ +export function determineLengthOfNode(node: Node): number { + switch (node.nodeType) { + // DocumentType: Zero. + // (not necessary, as doctypes never have children) + + // Text, ProcessingInstruction, Comment: The number of code units in its data. + case NodeType.TEXT_NODE: + case NodeType.PROCESSING_INSTRUCTION_NODE: + case NodeType.COMMENT_NODE: + return (node as CharacterData).data.length; + + // Any other node: Its number of children. + default: + return node.childNodes.length; + } +} + +/** + * Get inclusive ancestors of the given node. + * + * @param node Node to get inclusive ancestors of + * + * @return Node's inclusive ancestors, in tree order + */ +export function getInclusiveAncestors(node: Node): Node[] { + let ancestor: Node | null = node; + let ancestors: Node[] = []; + while (ancestor) { + ancestors.unshift(ancestor); + ancestor = ancestor.parentNode; + } + + return ancestors; +} + +/** + * Get the node document associated with the given node. + * + * @param node The node to get the node document for + * + * @return The node document for node + */ +export function getNodeDocument(node: Node): Document { + if (isNodeOfType(node, NodeType.DOCUMENT_NODE)) { + return node as Document; + } + + return node.ownerDocument!; +} + +/** + * Determine the index of the given node among its siblings. + * + * @param node Node to determine the index of + * + * @return The index of node in its parent's children + */ +export function getNodeIndex(node: Node): number { + return (node.parentNode as Node).childNodes.indexOf(node); +} + +/** + * The root of an object is itself, if its parent is null, or else it is the root of its parent. + * + * @param node Node to get the root of + * + * @return The root of node + */ +export function getRootOfNode(node: Node): Node { + while (node.parentNode) { + node = node.parentNode; + } + + return node; +} + +/** + * Invokes callback on each inclusive descendant of node, in tree order + * + * @param node Root of the subtree to process + * @param callback Callback to invoke for each descendant, should not modify node's position in the tree + */ +export function forEachInclusiveDescendant(node: Node, callback: (node: Node) => void): void { + callback(node); + for (let child = node.firstChild; child; child = child.nextSibling) { + forEachInclusiveDescendant(child, callback); + } +} diff --git a/src/util/treeMutations.ts b/src/util/treeMutations.ts new file mode 100644 index 0000000..eaca193 --- /dev/null +++ b/src/util/treeMutations.ts @@ -0,0 +1,150 @@ +import { asParentNode, asNonDocumentTypeChildNode } from '../mixins'; +import Document from '../Document'; +import DocumentType from '../DocumentType'; +import Element from '../Element'; +import Node from '../Node'; + +import { NodeType, isNodeOfType } from './NodeType'; + +/** + * Insert node into parent's children before referenceNode. + * + * Updates the pointers that model the tree, as well as precomputing derived properties. + * + * @param node Node to insert + * @param parent Parent to insert under + * @param referenceChild Child to insert before + */ +export function insertIntoChildren(node: Node, parent: Node, referenceChild: Node | null): void { + // Node + node.parentNode = parent; + const previousSibling: Node | null = referenceChild === null ? parent.lastChild : referenceChild.previousSibling; + const nextSibling: Node | null = referenceChild === null ? null : referenceChild; + node.previousSibling = previousSibling; + node.nextSibling = nextSibling; + if (previousSibling) { + previousSibling.nextSibling = node; + } else { + parent.firstChild = node; + } + if (nextSibling) { + nextSibling.previousSibling = node; + parent.childNodes.splice(parent.childNodes.indexOf(nextSibling), 0, node); + } else { + parent.lastChild = node; + parent.childNodes.push(node); + } + + // ParentNode + if (isNodeOfType(node, NodeType.ELEMENT_NODE)) { + const element = node as Element; + const parentNode = asParentNode(parent); + // Functions calling this will ensure parent is always a ParentNode + /* istanbul ignore else */ + if (parentNode) { + let previousElementSibling: Element | null = null; + for (let sibling = previousSibling; sibling; sibling = sibling.previousSibling) { + if (isNodeOfType(sibling, NodeType.ELEMENT_NODE)) { + previousElementSibling = sibling as Element; + break; + } + const siblingNonDocumentTypeChildNode = asNonDocumentTypeChildNode(sibling); + if (siblingNonDocumentTypeChildNode) { + previousElementSibling = siblingNonDocumentTypeChildNode.previousElementSibling; + break; + } + } + + let nextElementSibling: Element | null = null; + for (let sibling = nextSibling; sibling; sibling = sibling.nextSibling) { + if (isNodeOfType(sibling, NodeType.ELEMENT_NODE)) { + nextElementSibling = sibling as Element; + break; + } + const siblingNonDocumentTypeChildNode = asNonDocumentTypeChildNode(sibling); + // An element can never be inserted before a doctype + /* istanbul ignore else */ + if (siblingNonDocumentTypeChildNode) { + nextElementSibling = siblingNonDocumentTypeChildNode.nextElementSibling; + break; + } + } + + if (!previousElementSibling) { + parentNode.firstElementChild = element; + } + if (!nextElementSibling) { + parentNode.lastElementChild = element; + } + parentNode.childElementCount += 1; + } + } + + // Document + if (isNodeOfType(parent, NodeType.DOCUMENT_NODE)) { + const parentDocument = parent as Document; + if (isNodeOfType(node, NodeType.ELEMENT_NODE)) { + parentDocument.documentElement = node as Element; + } else if (isNodeOfType(node, NodeType.DOCUMENT_TYPE_NODE)) { + parentDocument.doctype = node as DocumentType; + } + } +} + +/** + * Remove node from parent's children. + * + * Updates the pointers that model the tree, as well as precomputing derived properties. + * + * @param node Node to remove + * @param parent Parent to remove from + */ +export function removeFromChildren(node: Node, parent: Node) { + const previousSibling = node.previousSibling; + const nextSibling = node.nextSibling; + const isElement = isNodeOfType(node, NodeType.ELEMENT_NODE); + const previousElementSibling = isElement ? (node as Element).previousElementSibling : null; + const nextElementSibling = isElement ? (node as Element).nextElementSibling : null; + + // Node + node.parentNode = null; + node.previousSibling = null; + node.nextSibling = null; + if (previousSibling) { + previousSibling.nextSibling = nextSibling; + } else { + parent.firstChild = nextSibling; + } + if (nextSibling) { + nextSibling.previousSibling = previousSibling; + } else { + parent.lastChild = previousSibling; + } + parent.childNodes.splice(parent.childNodes.indexOf(node), 1); + + // ParentNode + if (isElement) { + const parentNode = asParentNode(parent); + // Functions calling this will ensure parent is always a ParentNode + /* istanbul ignore else */ + if (parentNode) { + if (parentNode.firstElementChild === node) { + parentNode.firstElementChild = nextElementSibling; + } + if (parentNode.lastElementChild === node) { + parentNode.lastElementChild = previousElementSibling; + } + parentNode.childElementCount -= 1; + } + } + + // Document + if (isNodeOfType(parent, NodeType.DOCUMENT_NODE)) { + const parentDocument = parent as Document; + if (isNodeOfType(node, NodeType.ELEMENT_NODE)) { + parentDocument.documentElement = null; + } else if (isNodeOfType(node, NodeType.DOCUMENT_TYPE_NODE)) { + parentDocument.doctype = null; + } + } +} diff --git a/src/util/typeHelpers.ts b/src/util/typeHelpers.ts new file mode 100644 index 0000000..603f26f --- /dev/null +++ b/src/util/typeHelpers.ts @@ -0,0 +1,38 @@ +import { expectObject } from './errorHelpers'; + +export function asUnsignedLong(number: number): number { + return number >>> 0; +} + +export function treatNullAsEmptyString(value: string | null): string { + // Treat null as empty string + if (value === null) { + return ''; + } + + // Coerce other values to string + return String(value); +} + +export function asObject(value: T, Constructor: any): T { + expectObject(value, Constructor); + + return value; +} + +export function asNullableObject(value: T | null | undefined, Constructor: any): T | null { + if (value === undefined || value === null) { + return null; + } + + return asObject(value, Constructor); +} + +export function asNullableString(value: string | null | undefined): string | null { + // Treat undefined as null + if (value === undefined) { + return null; + } + + return value; +} diff --git a/test/Attr.tests.ts b/test/Attr.tests.ts new file mode 100644 index 0000000..11a0669 --- /dev/null +++ b/test/Attr.tests.ts @@ -0,0 +1,92 @@ +import * as chai from 'chai'; +import * as slimdom from '../src/index'; + +describe('Attr', () => { + let document: slimdom.Document; + beforeEach(() => { + document = new slimdom.Document(); + }); + + it('can be created using Document#createAttribute()', () => { + const attr = document.createAttribute('test'); + chai.assert.equal(attr.nodeType, 2); + chai.assert.equal(attr.nodeName, 'test'); + chai.assert.equal(attr.nodeValue, ''); + + chai.assert.equal(attr.namespaceURI, null); + chai.assert.equal(attr.prefix, null); + chai.assert.equal(attr.localName, 'test'); + chai.assert.equal(attr.name, 'test'); + chai.assert.equal(attr.value, ''); + }); + + it('can be created using Document#createAttributeNS()', () => { + const attr = document.createAttributeNS('http://www.example.com/ns', 'ns:test'); + chai.assert.equal(attr.nodeType, 2); + chai.assert.equal(attr.nodeName, 'ns:test'); + chai.assert.equal(attr.nodeValue, ''); + + chai.assert.equal(attr.namespaceURI, 'http://www.example.com/ns'); + chai.assert.equal(attr.prefix, 'ns'); + chai.assert.equal(attr.localName, 'test'); + chai.assert.equal(attr.name, 'ns:test'); + chai.assert.equal(attr.value, ''); + }); + + it('can set its value using nodeValue', () => { + const attr = document.createAttribute('test'); + attr.nodeValue = 'value'; + chai.assert.equal(attr.nodeValue, 'value'); + chai.assert.equal(attr.value, 'value'); + + attr.nodeValue = null; + chai.assert.equal(attr.nodeValue, ''); + chai.assert.equal(attr.value, ''); + }); + + it('can set its value using value', () => { + const attr = document.createAttribute('test'); + attr.value = 'value'; + chai.assert.equal(attr.nodeValue, 'value'); + chai.assert.equal(attr.value, 'value'); + }); + + it('can set its value when part of an element', () => { + const element = document.createElement('test'); + element.setAttribute('attr', 'value'); + const attr = element.getAttributeNode('attr')!; + chai.assert.equal(attr.value, 'value'); + + attr.value = 'new value'; + chai.assert.equal(element.getAttribute('attr'), 'new value'); + }); + + it('can be cloned', () => { + const attr = document.createAttributeNS('http://www.example.com/ns', 'ns:test'); + attr.value = 'some value'; + + const copy = attr.cloneNode() as slimdom.Attr; + chai.assert.equal(copy.nodeType, 2); + chai.assert.equal(copy.nodeName, 'ns:test'); + chai.assert.equal(copy.nodeValue, 'some value'); + + chai.assert.equal(copy.namespaceURI, 'http://www.example.com/ns'); + chai.assert.equal(copy.prefix, 'ns'); + chai.assert.equal(copy.localName, 'test'); + chai.assert.equal(copy.name, 'ns:test'); + chai.assert.equal(copy.value, 'some value'); + + chai.assert.notEqual(copy, attr); + }); + + it('can lookup a prefix or namespace on its owner element', () => { + const attr = document.createAttribute('attr'); + chai.assert.equal(attr.lookupNamespaceURI('prf'), null); + chai.assert.equal(attr.lookupPrefix('http://www.example.com/ns'), null); + + const element = document.createElementNS('http://www.example.com/ns', 'prf:test'); + element.setAttributeNode(attr); + chai.assert.equal(attr.lookupNamespaceURI('prf'), 'http://www.example.com/ns'); + chai.assert.equal(attr.lookupPrefix('http://www.example.com/ns'), 'prf'); + }); +}); diff --git a/test/CDATASection.tests.ts b/test/CDATASection.tests.ts new file mode 100644 index 0000000..cb6b3cc --- /dev/null +++ b/test/CDATASection.tests.ts @@ -0,0 +1,27 @@ +import * as chai from 'chai'; +import * as slimdom from '../src/index'; + +describe('CDATASection', () => { + let document: slimdom.Document; + beforeEach(() => { + document = new slimdom.Document(); + }); + + it('can be created', () => { + const cs = document.createCDATASection('some content'); + chai.assert.equal(cs.nodeType, 4); + chai.assert.equal(cs.nodeName, '#cdata-section'); + chai.assert.equal(cs.nodeValue, 'some content'); + chai.assert.equal(cs.data, 'some content'); + }); + + it('can be cloned', () => { + const cs = document.createCDATASection('some content'); + const copy = cs.cloneNode() as slimdom.CDATASection; + chai.assert.equal(copy.nodeType, 4); + chai.assert.equal(copy.nodeName, '#cdata-section'); + chai.assert.equal(copy.nodeValue, 'some content'); + chai.assert.equal(copy.data, 'some content'); + chai.assert.notEqual(copy, cs); + }); +}); diff --git a/test/Comment.tests.ts b/test/Comment.tests.ts index 7066db3..5b610b6 100644 --- a/test/Comment.tests.ts +++ b/test/Comment.tests.ts @@ -1,30 +1,79 @@ -import slimdom from '../src/index'; - -import Comment from '../src/Comment'; -import Document from '../src/Document'; - import * as chai from 'chai'; +import * as slimdom from '../src/index'; describe('Comment', () => { - let document: Document; - let comment: Comment; + let document: slimdom.Document; beforeEach(() => { - document = slimdom.createDocument(); - comment = document.createComment('somedata'); + document = new slimdom.Document(); + }); + + it('can be created using Document#createComment()', () => { + const comment = document.createComment('some data'); + chai.assert.equal(comment.nodeType, 8); + chai.assert.equal(comment.nodeName, '#comment'); + chai.assert.equal(comment.nodeValue, 'some data'); + chai.assert.equal(comment.data, 'some data'); }); - it('has nodeType 8', () => chai.assert.equal(comment.nodeType, 8)); + it('can be created using its constructor (with data)', () => { + const comment = new slimdom.Comment('some data'); + chai.assert.equal(comment.nodeType, 8); + chai.assert.equal(comment.nodeName, '#comment'); + chai.assert.equal(comment.nodeValue, 'some data'); + chai.assert.equal(comment.data, 'some data'); - it('has data', () => { - chai.assert.equal(comment.nodeValue, 'somedata'); - chai.assert.equal(comment.data, 'somedata'); + chai.assert.equal(comment.ownerDocument, slimdom.document); + }); + + it('can be created using its constructor (without arguments)', () => { + const comment = new slimdom.Comment(); + chai.assert.equal(comment.nodeType, 8); + chai.assert.equal(comment.nodeName, '#comment'); + chai.assert.equal(comment.nodeValue, ''); + chai.assert.equal(comment.data, ''); + + chai.assert.equal(comment.ownerDocument, slimdom.document); + }); + + it('can set its data using nodeValue', () => { + const comment = document.createComment('some data'); + comment.nodeValue = 'other data'; + chai.assert.equal(comment.nodeValue, 'other data'); + chai.assert.equal(comment.data, 'other data'); + + comment.nodeValue = null; + chai.assert.equal(comment.nodeValue, ''); + chai.assert.equal(comment.data, ''); + }); + + it('can set its data using data', () => { + const comment = document.createComment('some data'); + comment.data = 'other data'; + chai.assert.equal(comment.nodeValue, 'other data'); + chai.assert.equal(comment.data, 'other data'); + (comment as any).data = null; + chai.assert.equal(comment.nodeValue, ''); + chai.assert.equal(comment.data, ''); }); it('can be cloned', () => { - var clone = comment.cloneNode(true) as Comment; - chai.assert.equal(clone.nodeType, 8); - chai.assert.equal(clone.nodeValue, 'somedata'); - chai.assert.equal(clone.data, 'somedata'); - chai.assert.notEqual(clone, comment); + const comment = document.createComment('some data'); + var copy = comment.cloneNode() as slimdom.Comment; + chai.assert.equal(copy.nodeType, 8); + chai.assert.equal(copy.nodeName, '#comment'); + chai.assert.equal(copy.nodeValue, 'some data'); + chai.assert.equal(copy.data, 'some data'); + chai.assert.notEqual(copy, comment); + }); + + it('can lookup a prefix or namespace on its parent element', () => { + const comment = document.createComment('some data'); + chai.assert.equal(comment.lookupNamespaceURI('prf'), null); + chai.assert.equal(comment.lookupPrefix('http://www.example.com/ns'), null); + + const element = document.createElementNS('http://www.example.com/ns', 'prf:test'); + element.appendChild(comment); + chai.assert.equal(comment.lookupNamespaceURI('prf'), 'http://www.example.com/ns'); + chai.assert.equal(comment.lookupPrefix('http://www.example.com/ns'), 'prf'); }); }); diff --git a/test/DOMImplementation.tests.ts b/test/DOMImplementation.tests.ts index 2384e6e..2b3bf10 100644 --- a/test/DOMImplementation.tests.ts +++ b/test/DOMImplementation.tests.ts @@ -1,12 +1,11 @@ -import DOMImplementation from '../src/DOMImplementation'; -import Element from '../src/Element'; - import * as chai from 'chai'; +import * as slimdom from '../src/index'; describe('DOMImplementation', () => { - let domImplementation: DOMImplementation; + let domImplementation: slimdom.DOMImplementation; beforeEach(() => { - domImplementation = new DOMImplementation(); + const document = new slimdom.Document(); + domImplementation = document.implementation; }); describe('.createDocumentType()', () => { @@ -39,7 +38,36 @@ describe('DOMImplementation', () => { const document = domImplementation.createDocument(null, 'someRootElementName'); chai.assert.equal(document.nodeType, 9); chai.assert.equal(document.firstChild, document.documentElement); - chai.assert.equal((document.documentElement as Element).nodeName, 'someRootElementName'); + chai.assert.equal((document.documentElement as slimdom.Element).nodeName, 'someRootElementName'); + }); + }); + + describe('.createHTMLDocument()', () => { + it('can create a document without a title', () => { + const document = domImplementation.createHTMLDocument(null); + const html = document.documentElement!; + chai.assert.equal(html.namespaceURI, 'http://www.w3.org/1999/xhtml'); + chai.assert.equal(html.localName, 'html'); + const head = html.firstElementChild!; + chai.assert.equal(head.localName, 'head'); + const body = html.lastElementChild!; + chai.assert.equal(body.localName, 'body'); + const title = head.firstElementChild; + chai.assert.equal(title, null); + }); + + it('can create a document with a title', () => { + const document = domImplementation.createHTMLDocument('some title'); + const html = document.documentElement!; + chai.assert.equal(html.namespaceURI, 'http://www.w3.org/1999/xhtml'); + chai.assert.equal(html.localName, 'html'); + const head = html.firstElementChild!; + chai.assert.equal(head.localName, 'head'); + const body = html.lastElementChild!; + chai.assert.equal(body.localName, 'body'); + const title = head.firstElementChild!; + chai.assert.equal(title.localName, 'title'); + chai.assert.equal((title.firstChild as slimdom.Text).data, 'some title'); }); }); }); diff --git a/test/Document.tests.ts b/test/Document.tests.ts index 6ff7ab3..0d30e85 100644 --- a/test/Document.tests.ts +++ b/test/Document.tests.ts @@ -1,37 +1,43 @@ -import slimdom from '../src/index'; - -import Document from '../src/Document'; -import DOMImplementation from '../src/DOMImplementation'; -import Element from '../src/Element'; -import Node from '../src/Node'; -import ProcessingInstruction from '../src/ProcessingInstruction'; - import * as chai from 'chai'; +import * as slimdom from '../src/index'; describe('Document', () => { - let document: Document; + let document: slimdom.Document; beforeEach(() => { - document = slimdom.createDocument(); + document = new slimdom.Document(); }); - it('has nodeType 9', () => chai.assert.equal(document.nodeType, 9)); + it('can be created using its constructor', () => { + const document = new slimdom.Document(); + chai.assert.equal(document.nodeType, 9); + chai.assert.equal(document.nodeName, '#document'); + chai.assert.equal(document.nodeValue, null); + }); - it('exposes its DOMImplementation', () => chai.assert.instanceOf(document.implementation, DOMImplementation)); + it('can not change its nodeValue', () => { + document.nodeValue = 'test'; + chai.assert.equal(document.nodeValue, null); + }); - it('initially has no doctype', () => chai.assert.equal(document.doctype, null)); + it('exposes its DOMImplementation', () => chai.assert.instanceOf(document.implementation, slimdom.DOMImplementation)); + + it('has a doctype property that reflects the presence of a doctype child', () => { + chai.assert.equal(document.doctype, null); + const doctype = document.implementation.createDocumentType('html', '', ''); + document.appendChild(doctype); + chai.assert.equal(document.doctype, doctype); + document.removeChild(doctype); + chai.assert.equal(document.doctype, null); + }); it('initially has no documentElement', () => chai.assert.equal(document.documentElement, null)); it('initially has no childNodes', () => chai.assert.deepEqual(document.childNodes, [])); - it('can have user data', () => { - chai.assert.equal(document.getUserData('test'), null); - document.setUserData('test', {abc: 123}); - chai.assert.deepEqual(document.getUserData('test'), {abc: 123}); - }); + it('initially has no children', () => chai.assert.deepEqual(document.children, [])); describe('after appending a child element', () => { - let element: Element; + let element: slimdom.Element; beforeEach(() => { element = document.createElement('test'); document.appendChild(element); @@ -39,7 +45,14 @@ describe('Document', () => { it('has a documentElement', () => chai.assert.equal(document.documentElement, element)); - it('has childNodes', () => chai.assert.deepEqual(document.childNodes, [ element ])); + it('has childNodes', () => chai.assert.deepEqual(document.childNodes, [element])); + + it('has children', () => chai.assert.deepEqual(document.children, [element])); + + it('has a first and last element child', () => { + chai.assert.equal(document.firstElementChild, element); + chai.assert.equal(document.lastElementChild, element); + }); it('the child element is adopted into the document', () => chai.assert.equal(element.ownerDocument, document)); @@ -51,10 +64,12 @@ describe('Document', () => { it('has no documentElement', () => chai.assert.equal(document.documentElement, null)); it('has no childNodes', () => chai.assert.deepEqual(document.childNodes, [])); + + it('has no children', () => chai.assert.deepEqual(document.children, [])); }); describe('after replacing the element', () => { - let otherElement: Element; + let otherElement: slimdom.Element; beforeEach(() => { otherElement = document.createElement('other'); document.replaceChild(otherElement, element); @@ -62,12 +77,14 @@ describe('Document', () => { it('has the other element as documentElement', () => chai.assert.equal(document.documentElement, otherElement)); - it('has childNodes', () => chai.assert.deepEqual(document.childNodes, [ otherElement ])); + it('has childNodes', () => chai.assert.deepEqual(document.childNodes, [otherElement])); + + it('has children', () => chai.assert.deepEqual(document.children, [otherElement])); }); }); describe('after appending a processing instruction', () => { - var processingInstruction: ProcessingInstruction; + var processingInstruction: slimdom.ProcessingInstruction; beforeEach(() => { processingInstruction = document.createProcessingInstruction('sometarget', 'somedata'); document.appendChild(processingInstruction); @@ -75,10 +92,12 @@ describe('Document', () => { it('has no documentElement', () => chai.assert.equal(document.documentElement, null)); - it('has childNodes', () => chai.assert.deepEqual(document.childNodes, [ processingInstruction ])); + it('has childNodes', () => chai.assert.deepEqual(document.childNodes, [processingInstruction])); + + it('has no children', () => chai.assert.deepEqual(document.children, [])); describe('after replacing with an element', () => { - let otherElement: Element; + let otherElement: slimdom.Element; beforeEach(() => { otherElement = document.createElement('other'); document.replaceChild(otherElement, processingInstruction); @@ -86,26 +105,183 @@ describe('Document', () => { it('has the other element as documentElement', () => chai.assert.equal(document.documentElement, otherElement)); - it('has childNodes', () => chai.assert.deepEqual(document.childNodes, [ otherElement ])); + it('has childNodes', () => chai.assert.deepEqual(document.childNodes, [otherElement])); + + it('has children', () => chai.assert.deepEqual(document.children, [otherElement])); }); }); - describe('cloning', () => { - var clone: Document; + describe('.cloneNode', () => { beforeEach(() => { document.appendChild(document.createElement('root')); - clone = document.cloneNode(true) as Document; }); - it('is a new document', () => { - chai.assert.equal(clone.nodeType, 9); - chai.assert.notEqual(clone, document); + it('can be cloned (shallow)', () => { + const copy = document.cloneNode() as slimdom.Document; + + chai.assert.equal(copy.nodeType, 9); + chai.assert.equal(copy.nodeName, '#document'); + chai.assert.equal(copy.nodeValue, null); + + chai.assert.equal(copy.documentElement, null); + + chai.assert.notEqual(copy, document); + }); + + it('can be cloned (deep)', () => { + const copy = document.cloneNode(true) as slimdom.Document; + + chai.assert.equal(copy.nodeType, 9); + chai.assert.equal(copy.nodeName, '#document'); + chai.assert.equal(copy.nodeValue, null); + + chai.assert.equal(copy.documentElement!.nodeName, 'root'); + + chai.assert.notEqual(copy, document); + chai.assert.notEqual(copy.documentElement, document.documentElement); + }); + }); + + it('can lookup a prefix or namespace on its document element', () => { + chai.assert.equal(document.lookupNamespaceURI('prf'), null); + chai.assert.equal(document.lookupPrefix('http://www.example.com/ns'), null); + + const element = document.createElementNS('http://www.example.com/ns', 'prf:test'); + document.appendChild(element); + chai.assert.equal(document.lookupNamespaceURI('prf'), 'http://www.example.com/ns'); + chai.assert.equal(document.lookupPrefix('http://www.example.com/ns'), 'prf'); + }); + + describe('.createElement', () => { + it('throws if not given a name', () => { + chai.assert.throws(() => (document as any).createElement(), TypeError); + }); + + it('throws if given an invalid name', () => { + chai.assert.throws(() => document.createElement(String.fromCodePoint(0x200b)), 'InvalidCharacterError'); + }); + }); + + describe('.createElementNS', () => { + it('throws if given an invalid name', () => { + chai.assert.throws(() => document.createElementNS(null, String.fromCodePoint(0x200b)), 'InvalidCharacterError'); + chai.assert.throws(() => document.createElementNS(null, 'a:b:c'), 'InvalidCharacterError'); + }); + + it('throws if given a prefixed name without a namespace', () => { + chai.assert.throws(() => document.createElementNS('', 'prf:test'), 'NamespaceError'); + }); + + it('throws if given an invalid use of a reserved prefix', () => { + chai.assert.throws(() => document.createElementNS('not the xml namespace', 'xml:test')); + chai.assert.throws(() => document.createElementNS('not the xmlns namespace', 'xmlns:test')); + chai.assert.throws(() => document.createElementNS('http://www.w3.org/2000/xmlns/', 'pre:test')); + }); + }); + + describe('.createCDATASection', () => { + it('throws if data contains "]]>"', () => { + chai.assert.throws(() => document.createCDATASection('meep]]>maap'), 'InvalidCharacterError'); + }); + }); + + describe('.createProcessingInstruction', () => { + it('throws if given an invalid target', () => { + chai.assert.throws( + () => document.createProcessingInstruction(String.fromCodePoint(0x200b), 'some data'), + 'InvalidCharacterError' + ); + }); + + it('throws if data contains "?>"', () => { + chai.assert.throws(() => document.createProcessingInstruction('target', 'some ?> data'), 'InvalidCharacterError'); + }); + }); + + describe('.importNode', () => { + let otherDocument: slimdom.Document; + beforeEach(() => { + otherDocument = new slimdom.Document(); + }); + + it('returns a clone with the document as node document', () => { + const element = otherDocument.createElement('test'); + chai.assert.equal(element.ownerDocument, otherDocument); + const copy = document.importNode(element); + chai.assert.equal(copy.ownerDocument, document); + chai.assert.equal(copy.nodeName, element.nodeName); + chai.assert.notEqual(copy, element); + chai.assert.deepEqual(copy.childNodes, []); + }); + + it('can clone descendants', () => { + const element = otherDocument.createElement('test'); + element.appendChild(otherDocument.createElement('child')).appendChild(otherDocument.createTextNode('content')); + chai.assert.equal(element.ownerDocument, otherDocument); + const copy = document.importNode(element, true) as slimdom.Element; + chai.assert.equal(copy.ownerDocument, document); + chai.assert.equal(copy.nodeName, element.nodeName); + chai.assert.notEqual(copy, element); + + const child = copy.firstElementChild!; + chai.assert.equal(child.nodeName, 'child'); + chai.assert.equal(child.ownerDocument, document); + chai.assert.notEqual(child, element.firstElementChild); + + chai.assert.equal(child.firstChild!.ownerDocument, document); + chai.assert.equal((child.firstChild as slimdom.Text).data, 'content'); + }); + + it('throws if given a document node', () => { + chai.assert.throws(() => document.importNode(otherDocument, true), 'NotSupportedError'); + }); + + it('throws if given something other than a node', () => { + chai.assert.throws(() => (document as any).importNode('not a node'), TypeError); + }); + }); + + describe('.adoptNode', () => { + let otherDocument: slimdom.Document; + beforeEach(() => { + otherDocument = new slimdom.Document(); + }); + + it('modifies the node to set the document as its node document', () => { + const element = otherDocument.createElement('test'); + chai.assert.equal(element.ownerDocument, otherDocument); + const adopted = document.adoptNode(element); + chai.assert.equal(adopted.ownerDocument, document); + chai.assert.equal(adopted.nodeName, element.nodeName); + chai.assert.equal(adopted, element); }); - it('has a new document element', () => { - chai.assert.equal((clone.documentElement as Node).nodeType, 1); - chai.assert.equal((clone.documentElement as Element).nodeName, 'root'); - chai.assert.notEqual(clone.documentElement, document.documentElement); + it('also adopts descendants and attributes', () => { + const element = otherDocument.createElement('test'); + element.appendChild(otherDocument.createElement('child')).appendChild(otherDocument.createTextNode('content')); + element.setAttribute('test', 'value'); + chai.assert.equal(element.ownerDocument, otherDocument); + const adopted = document.adoptNode(element) as slimdom.Element; + chai.assert.equal(adopted.ownerDocument, document); + chai.assert.equal(adopted.nodeName, element.nodeName); + chai.assert.equal(adopted, element); + + const child = adopted.firstElementChild!; + chai.assert.equal(child.ownerDocument, document); + chai.assert.equal(child.firstChild!.ownerDocument, document); + + const attr = adopted.getAttributeNode('test'); + chai.assert.equal(attr!.ownerDocument, document); + }); + + it('throws if given a document node', () => { + chai.assert.throws(() => document.adoptNode(otherDocument), 'NotSupportedError'); + }); + }); + + describe('.createAttribute', () => { + it('throws if given an invalid name', () => { + chai.assert.throws(() => document.createAttribute(String.fromCodePoint(0x200b)), 'InvalidCharacterError'); }); }); }); diff --git a/test/DocumentFragment.tests.ts b/test/DocumentFragment.tests.ts new file mode 100644 index 0000000..30baed4 --- /dev/null +++ b/test/DocumentFragment.tests.ts @@ -0,0 +1,111 @@ +import * as chai from 'chai'; +import * as slimdom from '../src/index'; + +describe('DocumentFragment', () => { + let document: slimdom.Document; + let fragment: slimdom.DocumentFragment; + beforeEach(() => { + document = new slimdom.Document(); + fragment = document.createDocumentFragment(); + }); + + it('can be created using Document#createDocumentFragment()', () => { + const df = document.createDocumentFragment(); + chai.assert.equal(df.nodeType, 11); + chai.assert.equal(df.nodeName, '#document-fragment'); + chai.assert.equal(df.nodeValue, null); + chai.assert.equal(df.ownerDocument, document); + }); + + it('can be created using its constructor', () => { + const df = new slimdom.DocumentFragment(); + chai.assert.equal(df.nodeType, 11); + chai.assert.equal(df.nodeName, '#document-fragment'); + chai.assert.equal(df.nodeValue, null); + chai.assert.equal(df.ownerDocument, slimdom.document); + }); + + it('can not change its nodeValue', () => { + fragment.nodeValue = 'test'; + chai.assert.equal(fragment.nodeValue, null); + }); + + it('can not lookup namespaces or prefixes', () => { + fragment.appendChild(document.createElementNS('http://www.example.com/ns', 'prf:test')); + chai.assert.equal(fragment.lookupNamespaceURI('prf'), null); + chai.assert.equal(fragment.lookupPrefix('http://www.example.com/ns'), null); + }); + + it('initially has no childNodes', () => chai.assert.deepEqual(fragment.childNodes, [])); + + it('initially has no children', () => chai.assert.deepEqual(fragment.children, [])); + + it('correctly updates its relation properties when children are added', () => { + const child1 = fragment.appendChild(document.createElement('child1')) as slimdom.Element; + const text = fragment.appendChild(document.createTextNode('text')); + const child2 = fragment.appendChild(document.createElement('child2')) as slimdom.Element; + const pi = fragment.appendChild(document.createProcessingInstruction('target', 'data')); + const child3 = fragment.appendChild(document.createElement('child3')) as slimdom.Element; + chai.assert.deepEqual(fragment.childNodes, [child1, text, child2, pi, child3]); + chai.assert.deepEqual(fragment.children, [child1, child2, child3]); + chai.assert.equal(fragment.firstElementChild, child1); + chai.assert.equal(fragment.firstElementChild!.nextElementSibling, child2); + chai.assert.equal(fragment.lastElementChild!.previousElementSibling, child2); + chai.assert.equal(fragment.lastElementChild, child3); + fragment.removeChild(child2); + chai.assert.deepEqual(fragment.childNodes, [child1, text, pi, child3]); + chai.assert.deepEqual(fragment.children, [child1, child3]); + chai.assert.equal(fragment.firstElementChild, child1); + chai.assert.equal(fragment.firstElementChild!.nextElementSibling, child3); + chai.assert.equal(fragment.lastElementChild!.previousElementSibling, child1); + chai.assert.equal(fragment.lastElementChild, child3); + }); + + it('inserts its children if inserted under another node', () => { + const child1 = fragment.appendChild(document.createElement('child1')) as slimdom.Element; + const text = fragment.appendChild(document.createTextNode('text')); + const child2 = fragment.appendChild(document.createElement('child2')) as slimdom.Element; + const pi = fragment.appendChild(document.createProcessingInstruction('target', 'data')); + const child3 = fragment.appendChild(document.createElement('child3')) as slimdom.Element; + const parent = document.createElement('parent'); + const existingChild = parent.appendChild(document.createComment('test')); + parent.insertBefore(fragment, existingChild); + chai.assert.deepEqual(parent.childNodes, [child1, text, child2, pi, child3, existingChild]); + chai.assert.deepEqual(parent.children, [child1, child2, child3]); + chai.assert.equal(parent.firstElementChild, child1); + chai.assert.equal(parent.firstElementChild!.nextElementSibling, child2); + chai.assert.equal(parent.lastElementChild!.previousElementSibling, child2); + chai.assert.equal(parent.lastElementChild, child3); + }); + + describe('.cloneNode', () => { + beforeEach(() => { + fragment.appendChild(document.createElement('root')); + }); + + it('can be cloned (shallow)', () => { + const copy = fragment.cloneNode() as slimdom.DocumentFragment; + + chai.assert.equal(copy.nodeType, 11); + chai.assert.equal(copy.nodeName, '#document-fragment'); + chai.assert.equal(copy.nodeValue, null); + + chai.assert.equal(copy.firstChild, null); + + chai.assert.notEqual(copy, fragment); + }); + + it('can be cloned (deep)', () => { + const copy = fragment.cloneNode(true) as slimdom.DocumentFragment; + + chai.assert.equal(copy.nodeType, 11); + chai.assert.equal(copy.nodeName, '#document-fragment'); + chai.assert.equal(copy.nodeValue, null); + + chai.assert.equal(copy.firstChild!.nodeName, 'root'); + + chai.assert.notEqual(copy, document); + chai.assert.notEqual(copy.firstChild, fragment.firstChild); + }); + }); +}); diff --git a/test/DocumentType.tests.ts b/test/DocumentType.tests.ts index e51c4d6..9596c4f 100644 --- a/test/DocumentType.tests.ts +++ b/test/DocumentType.tests.ts @@ -1,29 +1,48 @@ -import slimdom from '../src/index'; - -import DocumentType from '../src/DocumentType'; - import * as chai from 'chai'; +import * as slimdom from '../src/index'; describe('DocumentType', () => { - let doctype: DocumentType; + let document: slimdom.Document; + let doctype: slimdom.DocumentType; beforeEach(() => { - doctype = slimdom.implementation.createDocumentType('somename', 'somePublicId', 'someSystemId'); + document = new slimdom.Document(); + doctype = document.implementation.createDocumentType('somename', 'somePublicId', 'someSystemId'); }); - it('has nodeType 10', () => chai.assert.equal(doctype.nodeType, 10)); - - it('has a name', () => chai.assert.equal(doctype.name, 'somename')); - - it('has a publicId', () => chai.assert.equal(doctype.publicId, 'somePublicId')); + it('can be created using DOMImplementation#createDocumentType', () => { + const doctype = document.implementation.createDocumentType( + 'HTML', + '-//W3C//DTD HTML 4.01//EN', + 'http://www.w3.org/TR/html4/strict.dtd' + ); + chai.assert.equal(doctype.nodeType, 10); + chai.assert.equal(doctype.nodeName, 'HTML'); + chai.assert.equal(doctype.nodeValue, null); + chai.assert.equal(doctype.name, 'HTML'); + chai.assert.equal(doctype.publicId, '-//W3C//DTD HTML 4.01//EN'); + chai.assert.equal(doctype.systemId, 'http://www.w3.org/TR/html4/strict.dtd'); + chai.assert.equal(doctype.ownerDocument, document); + }); - it('has a systemId', () => chai.assert.equal(doctype.systemId, 'someSystemId')); + it('can not change its nodeValue', () => { + doctype.nodeValue = 'test'; + chai.assert.equal(document.nodeValue, null); + }); it('can be cloned', () => { - const clone = doctype.cloneNode(true) as DocumentType; - chai.assert.equal(clone.nodeType, 10); - chai.assert.equal(clone.name, 'somename'); - chai.assert.equal(clone.publicId, 'somePublicId'); - chai.assert.equal(clone.systemId, 'someSystemId'); - chai.assert.notEqual(clone, doctype); + const copy = doctype.cloneNode(true) as slimdom.DocumentType; + chai.assert.equal(copy.nodeType, 10); + chai.assert.equal(copy.name, 'somename'); + chai.assert.equal(copy.publicId, 'somePublicId'); + chai.assert.equal(copy.systemId, 'someSystemId'); + chai.assert.equal(copy.ownerDocument, document); + chai.assert.notEqual(copy, doctype); + }); + + it('can not lookup namespaces or prefixes', () => { + document.appendChild(doctype); + document.appendChild(document.createElementNS('http://www.example.com/ns', 'prf:test')); + chai.assert.equal(doctype.lookupNamespaceURI('prf'), null); + chai.assert.equal(doctype.lookupPrefix('http://www.example.com/ns'), null); }); }); diff --git a/test/Element.tests.ts b/test/Element.tests.ts index 4f23288..e01f073 100644 --- a/test/Element.tests.ts +++ b/test/Element.tests.ts @@ -1,92 +1,192 @@ -import slimdom from '../src/index' - -import Document from '../src/Document'; -import Element from '../src/Element'; -import Node from '../src/Node'; -import ProcessingInstruction from '../src/ProcessingInstruction'; -import Text from '../src/Text'; - import * as chai from 'chai'; +import * as slimdom from '../src/index'; describe('Element', () => { - let document: Document; - let element: Element; + let document: slimdom.Document; + let element: slimdom.Element; beforeEach(() => { - document = slimdom.createDocument(); - element = document.createElement('root'); + document = new slimdom.Document(); + element = document.createElementNS('http://www.w3.org/2000/svg', 'svg:g'); + }); + + it('can be created using Document#createElement', () => { + const element = document.createElement('test'); + chai.assert.equal(element.nodeType, 1); + chai.assert.equal(element.nodeName, 'test'); + chai.assert.equal(element.nodeValue, null); + chai.assert.equal(element.ownerDocument, document); + chai.assert.equal(element.namespaceURI, null); + chai.assert.equal(element.localName, 'test'); + chai.assert.equal(element.prefix, null); + }); + + it('can be created using Document#createElementNS', () => { + const element = document.createElementNS('http://www.example.com/ns', 'prf:test'); + chai.assert.equal(element.nodeType, 1); + chai.assert.equal(element.nodeName, 'prf:test'); + chai.assert.equal(element.nodeValue, null); + chai.assert.equal(element.ownerDocument, document); + chai.assert.equal(element.namespaceURI, 'http://www.example.com/ns'); + chai.assert.equal(element.localName, 'test'); + chai.assert.equal(element.prefix, 'prf'); + }); + + it('can not change its nodeValue', () => { + element.nodeValue = 'test'; + chai.assert.equal(element.nodeValue, null); + }); + + it('can lookup its own prefix or namespace', () => { + chai.assert.equal(element.lookupPrefix(null), null); + chai.assert.equal(element.lookupNamespaceURI(''), null); + chai.assert.equal(element.lookupNamespaceURI('svg'), 'http://www.w3.org/2000/svg'); + chai.assert.equal(element.lookupPrefix('http://www.w3.org/2000/svg'), 'svg'); + }); + + it('can lookup a prefix or namespace declared on itself', () => { + element.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns', 'http://www.w3.org/2000/svg'); + element.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:prf', 'http://www.example.com/ns'); + chai.assert.equal(element.lookupPrefix(null), null); + chai.assert.equal(element.lookupNamespaceURI('prf'), 'http://www.example.com/ns'); + chai.assert.equal(element.lookupPrefix('http://www.example.com/ns'), 'prf'); + chai.assert.equal(element.lookupNamespaceURI(null), 'http://www.w3.org/2000/svg'); + chai.assert.equal((element as any).lookupNamespaceURI(undefined), 'http://www.w3.org/2000/svg'); + chai.assert.equal(element.lookupPrefix('http://www.w3.org/2000/svg'), 'svg'); }); - it('has nodeType 1', () => chai.assert.equal(element.nodeType, 1)); + it('can lookup a prefix or namespace declared on an ancestor', () => { + const parent = document.createElement('svg'); + parent.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns', ''); + parent.appendChild(element); + parent.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:prf', 'http://www.example.com/ns'); + chai.assert.equal(element.lookupPrefix(null), null); + chai.assert.equal(element.lookupNamespaceURI(null), null); + chai.assert.equal(element.lookupNamespaceURI('prf'), 'http://www.example.com/ns'); + chai.assert.equal(element.lookupPrefix('http://www.example.com/ns'), 'prf'); + chai.assert.equal(element.lookupPrefix('unknown'), null); + chai.assert.equal(element.lookupNamespaceURI('unknown'), null); + }); - it('is owned by the document', () => chai.assert.equal(element.ownerDocument, document)); + it('can check the default namespace', () => { + const element = document.createElementNS('http://www.w3.org/1999/xhtml', 'html'); + chai.assert(!element.isDefaultNamespace('http://www.w3.org/2000/svg')); + chai.assert(element.isDefaultNamespace('http://www.w3.org/1999/xhtml')); + chai.assert(document.createElement('test').isDefaultNamespace('')); + }); - it('initially has no child nodes', () => { + it('initially has no childNodes', () => { chai.assert.equal(element.firstChild, null); chai.assert.equal(element.lastChild, null); + chai.assert(!element.hasChildNodes()); chai.assert.deepEqual(element.childNodes, []); }); - it('initially has no child elements', () => { + it('initially has no children', () => { chai.assert.equal(element.firstElementChild, null); chai.assert.equal(element.lastElementChild, null); - // TODO: Element.children not yet supported - //chai.assert.deepEqual(element.children, []); + chai.assert.deepEqual(element.children, []); chai.assert.equal(element.childElementCount, 0); }); it('initially has no attributes', () => { - chai.assert(!element.hasAttribute('test')); - chai.assert.equal(element.getAttribute('test'), null); - chai.assert.deepEqual(element.attributes, []); + chai.assert.equal(element.hasAttributes(), false); + chai.assert.deepEqual(Array.from(element.attributes), []); }); describe('setting attributes', () => { beforeEach(() => { element.setAttribute('firstAttribute', 'first'); element.setAttribute('test', '123'); - element.setAttribute('lastAttribute', 'last'); + element.setAttributeNS('http://www.example.com/ns', 'prf:lastAttribute', 'last'); + }); + + it('throws if the attribute name is invalid', () => { + chai.assert.throws(() => element.setAttribute(String.fromCodePoint(0x200b), 'value')); }); it('has the attributes', () => { + chai.assert(element.hasAttributes()); chai.assert(element.hasAttribute('firstAttribute'), 'has attribute firstAttribute'); + chai.assert(element.hasAttributeNS(null, 'firstAttribute'), 'has attribute firstAttribute'); chai.assert(element.hasAttribute('test'), 'has attribute test'); - chai.assert(element.hasAttribute('lastAttribute'), 'has attribute lastAttribute'); + chai.assert(element.hasAttributeNS(null, 'test'), 'has attribute test'); + chai.assert(element.hasAttribute('prf:lastAttribute'), 'has attribute lastAttribute'); + chai.assert(element.hasAttributeNS('http://www.example.com/ns', 'lastAttribute'), 'has attribute lastAttribute'); chai.assert(!element.hasAttribute('noSuchAttribute'), 'does not have attribute noSuchAttribute'); + chai.assert( + !element.hasAttributeNS(null, 'prf:lastAttribute'), + 'does not have attribute prf:lastAttribute without namespace' + ); }); it('returns the attribute value', () => { chai.assert.equal(element.getAttribute('firstAttribute'), 'first'); + chai.assert.equal(element.getAttributeNS('', 'firstAttribute'), 'first'); chai.assert.equal(element.getAttribute('test'), '123'); - chai.assert.equal(element.getAttribute('lastAttribute'), 'last'); + chai.assert.equal(element.getAttributeNS(null, 'test'), '123'); + chai.assert.equal(element.getAttribute('prf:lastAttribute'), 'last'); + chai.assert.equal(element.getAttributeNS('http://www.example.com/ns', 'lastAttribute'), 'last'); chai.assert.equal(element.getAttribute('noSuchAttribute'), null); + chai.assert.equal(element.getAttributeNS(null, 'prf:noSuchAttribute'), null); }); - it('has attributes', () => chai.assert.deepEqual(element.attributes, [ - {name: 'firstAttribute', value: 'first'}, - {name: 'test', value: '123'}, - {name: 'lastAttribute', value: 'last'} - ])); + function hasAttributes(attributes: slimdom.Attr[], expected: { name: string; value: string }[]): boolean { + return ( + attributes.length === expected.length && + attributes.every(attr => expected.some(pair => pair.name === attr.name && pair.value === attr.value)) && + expected.every(pair => attributes.some(attr => attr.name === pair.name && attr.value === pair.value)) + ); + } + + it('has attributes', () => + chai.assert( + hasAttributes(element.attributes, [ + { name: 'firstAttribute', value: 'first' }, + { name: 'test', value: '123' }, + { name: 'prf:lastAttribute', value: 'last' } + ]) + )); it('can overwrite the attribute', () => { element.setAttribute('test', '456'); chai.assert(element.hasAttribute('test'), 'has the attribute'); chai.assert.equal(element.getAttribute('test'), '456'); - chai.assert.deepEqual(element.attributes, [ - {name: 'firstAttribute', value: 'first'}, - {name: 'test', value: '456'}, - {name: 'lastAttribute', value: 'last'} - ]); + chai.assert( + hasAttributes(element.attributes, [ + { name: 'firstAttribute', value: 'first' }, + { name: 'test', value: '456' }, + { name: 'prf:lastAttribute', value: 'last' } + ]) + ); + + element.setAttributeNS('http://www.example.com/ns', 'prf:lastAttribute', 'new value'); + chai.assert(element.hasAttributeNS('http://www.example.com/ns', 'lastAttribute'), 'has the attribute'); + chai.assert.equal(element.getAttributeNS('http://www.example.com/ns', 'lastAttribute'), 'new value'); + chai.assert( + hasAttributes(element.attributes, [ + { name: 'firstAttribute', value: 'first' }, + { name: 'test', value: '456' }, + { name: 'prf:lastAttribute', value: 'new value' } + ]) + ); }); it('can remove the attribute', () => { element.removeAttribute('test'); chai.assert(element.hasAttribute('firstAttribute'), 'has attribute firstAttribute'); chai.assert(!element.hasAttribute('test'), 'does not have attribute test'); - chai.assert(element.hasAttribute('lastAttribute'), 'has attribute lastAttribute'); - chai.assert.deepEqual(element.attributes, [ - {name: 'firstAttribute', value: 'first'}, - {name: 'lastAttribute', value: 'last'} - ]); + chai.assert(element.hasAttribute('prf:lastAttribute'), 'has attribute lastAttribute'); + chai.assert( + hasAttributes(element.attributes, [ + { name: 'firstAttribute', value: 'first' }, + { name: 'prf:lastAttribute', value: 'last' } + ]) + ); + element.removeAttributeNS('http://www.example.com/ns', 'lastAttribute'); + chai.assert(hasAttributes(element.attributes, [{ name: 'firstAttribute', value: 'first' }])); + // Removing something that doesn't exist does nothing + element.removeAttributeNS('http://www.example.com/ns', 'missingAttribute'); + chai.assert(hasAttributes(element.attributes, [{ name: 'firstAttribute', value: 'first' }])); }); it('ignores removing non-existent attributes', () => { @@ -94,16 +194,53 @@ describe('Element', () => { element.removeAttribute('other'); chai.assert(!element.hasAttribute('other'), 'does not have attribute other'); chai.assert(element.hasAttribute('test'), 'has attribute test'); - chai.assert.deepEqual(element.attributes, [ - {name: 'firstAttribute', value: 'first'}, - {name: 'test', value: '123'}, - {name: 'lastAttribute', value: 'last'} - ]); + chai.assert( + hasAttributes(element.attributes, [ + { name: 'firstAttribute', value: 'first' }, + { name: 'test', value: '123' }, + { name: 'prf:lastAttribute', value: 'last' } + ]) + ); + }); + + it('can set attributes using their nodes', () => { + const attr = document.createAttribute('attr'); + attr.value = 'some value'; + chai.assert.equal(element.setAttributeNodeNS(attr), null); + const namespacedAttr = document.createAttributeNS('http://www.example.com/ns', 'prf:aaa'); + element.setAttributeNode(namespacedAttr); + chai.assert( + hasAttributes(element.attributes, [ + { name: 'firstAttribute', value: 'first' }, + { name: 'test', value: '123' }, + { name: 'prf:lastAttribute', value: 'last' }, + { name: 'attr', value: 'some value' }, + { name: 'prf:aaa', value: '' } + ]) + ); + + // It returns the previous attribute node + chai.assert.equal(element.setAttributeNode(attr), attr); + chai.assert.equal(element.setAttributeNode(document.createAttribute('attr')), attr); + + const otherElement = document.createElement('test'); + chai.assert.throws(() => otherElement.setAttributeNode(namespacedAttr), 'InUseAttributeError'); + }); + + it('can remove attributes using their nodes', () => { + const attr = element.removeAttributeNode(element.attributes[1]); + chai.assert( + hasAttributes(element.attributes, [ + { name: 'firstAttribute', value: 'first' }, + { name: 'prf:lastAttribute', value: 'last' } + ]) + ); + chai.assert.throws(() => element.removeAttributeNode(attr), 'NotFoundError'); }); }); describe('after appending a child element', () => { - let child: Element; + let child: slimdom.Element; beforeEach(() => { child = document.createElement('child'); element.appendChild(child); @@ -118,8 +255,7 @@ describe('Element', () => { it('has child element references', () => { chai.assert.equal(element.firstElementChild, child); chai.assert.equal(element.lastElementChild, child); - // TODO: Element.children not yet supported - //chai.assert.deepEqual(element.children, [ child ]); + chai.assert.deepEqual(element.children, [child]); chai.assert.equal(element.childElementCount, 1); }); @@ -137,14 +273,13 @@ describe('Element', () => { it('has no child elements', () => { chai.assert.equal(element.firstElementChild, null); chai.assert.equal(element.lastElementChild, null); - // TODO: Element.children not yet supported - //chai.assert.deepEqual(element.children, []); + chai.assert.deepEqual(element.children, []); chai.assert.equal(element.childElementCount, 0); }); }); describe('after replacing the child element', () => { - let otherChild: Element; + let otherChild: slimdom.Element; beforeEach(() => { otherChild = document.createElement('other'); element.replaceChild(otherChild, child); @@ -153,20 +288,19 @@ describe('Element', () => { it('has child node references', () => { chai.assert.equal(element.firstChild, otherChild); chai.assert.equal(element.lastChild, otherChild); - chai.assert.deepEqual(element.childNodes, [ otherChild ]); + chai.assert.deepEqual(element.childNodes, [otherChild]); }); it('has child element references', () => { chai.assert.equal(element.firstElementChild, otherChild); chai.assert.equal(element.lastElementChild, otherChild); - // TODO: Element.children not yet supported - //chai.assert.deepEqual(element.children, [ otherChild ]); + chai.assert.deepEqual(element.children, [otherChild]); chai.assert.equal(element.childElementCount, 1); }); }); describe('after inserting an element before the child', () => { - let otherChild: Element; + let otherChild: slimdom.Element; beforeEach(() => { otherChild = document.createElement('other'); element.insertBefore(otherChild, child); @@ -175,14 +309,13 @@ describe('Element', () => { it('has child node references', () => { chai.assert.equal(element.firstChild, otherChild); chai.assert.equal(element.lastChild, child); - chai.assert.deepEqual(element.childNodes, [ otherChild, child ]); + chai.assert.deepEqual(element.childNodes, [otherChild, child]); }); it('has child element references', () => { chai.assert.equal(element.firstElementChild, otherChild); chai.assert.equal(element.lastElementChild, child); - // TODO: Element.children not yet supported - //chai.assert.deepEqual(element.children, [ otherChild, child ]); + chai.assert.deepEqual(element.children, [otherChild, child]); chai.assert.equal(element.childElementCount, 2); }); @@ -200,7 +333,7 @@ describe('Element', () => { }); describe('after inserting an element after the child', () => { - let otherChild: Element; + let otherChild: slimdom.Element; beforeEach(() => { otherChild = document.createElement('other'); element.appendChild(otherChild); @@ -215,8 +348,7 @@ describe('Element', () => { it('has child element references', () => { chai.assert.equal(element.firstElementChild, child); chai.assert.equal(element.lastElementChild, otherChild); - // TODO: Element.children not yet supported - //chai.assert.deepEqual(element.children, [ child, otherChild ]); + chai.assert.deepEqual(element.children, [child, otherChild]); chai.assert.equal(element.childElementCount, 2); }); @@ -241,14 +373,13 @@ describe('Element', () => { it('has child node references', () => { chai.assert.equal(element.firstChild, child); chai.assert.equal(element.lastChild, child); - chai.assert.deepEqual(element.childNodes, [ child ]); + chai.assert.deepEqual(element.childNodes, [child]); }); it('has child element references', () => { chai.assert.equal(element.firstElementChild, child); chai.assert.equal(element.lastElementChild, child); - // TODO: Element.children not yet supported - //chai.assert.deepEqual(element.children, [ child ]); + chai.assert.deepEqual(element.children, [child]); chai.assert.equal(element.childElementCount, 1); }); @@ -262,7 +393,7 @@ describe('Element', () => { }); describe('after appending a processing instruction', () => { - let processingInstruction: ProcessingInstruction; + let processingInstruction: slimdom.ProcessingInstruction; beforeEach(() => { processingInstruction = document.createProcessingInstruction('test', 'test'); element.appendChild(processingInstruction); @@ -271,35 +402,33 @@ describe('Element', () => { it('has child node references', () => { chai.assert.equal(element.firstChild, processingInstruction); chai.assert.equal(element.lastChild, processingInstruction); - chai.assert.deepEqual(element.childNodes, [ processingInstruction ]); + chai.assert.deepEqual(element.childNodes, [processingInstruction]); }); it('has no child elements', () => { chai.assert.equal(element.firstElementChild, null); chai.assert.equal(element.lastElementChild, null); - // TODO: Element.children not yet supported - //chai.assert.deepEqual(element.children, []); + chai.assert.deepEqual(element.children, []); chai.assert.equal(element.childElementCount, 0); }); describe('after replacing with an element', () => { - let otherChild: Element; + let otherChild: slimdom.Element; beforeEach(() => { otherChild = document.createElement('other'); - element.replaceChild(otherChild, element.firstChild as Node); + element.replaceChild(otherChild, element.firstChild!); }); it('has child node references', () => { chai.assert.equal(element.firstChild, otherChild); chai.assert.equal(element.lastChild, otherChild); - chai.assert.deepEqual(element.childNodes, [ otherChild ]); + chai.assert.deepEqual(element.childNodes, [otherChild]); }); it('has child element references', () => { chai.assert.equal(element.firstElementChild, otherChild); chai.assert.equal(element.lastElementChild, otherChild); - // TODO: Element.children not yet supported - //chai.assert.deepEqual(element.children, [ otherChild ]); + chai.assert.deepEqual(element.children, [otherChild]); chai.assert.equal(element.childElementCount, 1); }); }); @@ -307,7 +436,7 @@ describe('Element', () => { describe('normalization', () => { it('removes empty text nodes', () => { - let textNode = element.appendChild(document.createTextNode('')) as Node; + let textNode = element.appendChild(document.createTextNode('')); element.normalize(); chai.assert.equal(textNode.parentNode, null); }); @@ -319,8 +448,97 @@ describe('Element', () => { chai.assert.equal(element.childNodes.length, 3); element.normalize(); chai.assert.equal(element.childNodes.length, 1); - chai.assert.equal((element.firstChild as Text).nodeValue, 'test123abc'); - chai.assert.equal((element.firstChild as Text).data, 'test123abc'); + chai.assert.equal((element.firstChild as slimdom.Text).nodeValue, 'test123abc'); + chai.assert.equal((element.firstChild as slimdom.Text).data, 'test123abc'); + }); + + it('recursively normalizes the entire subtree', () => { + element.appendChild(document.createTextNode('test')); + element.appendChild(document.createTextNode('123')); + element.appendChild(document.createTextNode('abc')); + const child = element.appendChild(document.createElement('child')); + child.appendChild(document.createTextNode('child')); + child.appendChild(document.createTextNode('')); + child.appendChild(document.createTextNode('content')); + const otherChild = element.appendChild(document.createElement('empty')); + otherChild.appendChild(document.createTextNode('text')); + otherChild.appendChild(document.createTextNode('')); + otherChild.appendChild(document.createTextNode('')); + element.normalize(); + chai.assert.equal(element.childNodes.length, 3); + chai.assert.equal((element.firstChild as slimdom.Text).nodeValue, 'test123abc'); + chai.assert.equal(child.childNodes.length, 1); + chai.assert.equal((child.firstChild as slimdom.Text).data, 'childcontent'); + chai.assert.equal(otherChild.childNodes.length, 1); + chai.assert.equal((otherChild.firstChild as slimdom.Text).data, 'text'); + }); + + it('adjusts ranges appropriately', () => { + let range1 = new slimdom.Range(); + let range2 = new slimdom.Range(); + element.appendChild(document.createTextNode('test')); + element.appendChild(document.createTextNode('123')); + element.appendChild(document.createTextNode('abc')); + range1.setStart(element.childNodes[1], 0); + range1.setEnd(element, 2); + range2.setStart(element, 1); + range2.setEnd(element.lastChild!, 0); + element.normalize(); + chai.assert.equal(range1.startContainer, element.firstChild); + chai.assert.equal(range1.startOffset, 4); + chai.assert.equal(range1.endContainer, element.firstChild); + chai.assert.equal(range1.endOffset, 7); + chai.assert.equal(range2.startContainer, element.firstChild); + chai.assert.equal(range2.startOffset, 4); + chai.assert.equal(range2.endContainer, element.firstChild); + chai.assert.equal(range2.endOffset, 7); + range1.detach(); + range2.detach(); + }); + }); + + describe('.cloneNode', () => { + beforeEach(() => { + document.appendChild(element); + element.setAttributeNS('http://www.example.com/ns', 'test', 'value'); + element.appendChild(document.createElement('child')); + }); + + it('can be cloned (shallow)', () => { + const copy = element.cloneNode() as slimdom.Element; + + chai.assert.equal(copy.nodeType, 1); + chai.assert.equal(copy.nodeName, 'svg:g'); + chai.assert.equal(copy.nodeValue, null); + chai.assert.equal(copy.ownerDocument, document); + chai.assert.equal(copy.namespaceURI, 'http://www.w3.org/2000/svg'); + chai.assert.equal(copy.localName, 'g'); + chai.assert.equal(copy.prefix, 'svg'); + chai.assert.equal(copy.ownerDocument, document); + chai.assert.equal(copy.firstChild, null); + chai.assert.notEqual(copy, element); + + chai.assert.equal(copy.getAttributeNS('http://www.example.com/ns', 'test'), 'value'); + }); + + it('can be cloned (deep)', () => { + const copy = element.cloneNode(true) as slimdom.Element; + + chai.assert.equal(copy.nodeType, 1); + chai.assert.equal(copy.nodeName, 'svg:g'); + chai.assert.equal(copy.nodeValue, null); + chai.assert.equal(copy.ownerDocument, document); + chai.assert.equal(copy.namespaceURI, 'http://www.w3.org/2000/svg'); + chai.assert.equal(copy.localName, 'g'); + chai.assert.equal(copy.prefix, 'svg'); + chai.assert.equal(copy.ownerDocument, document); + chai.assert.notEqual(copy, element); + + chai.assert.equal(copy.getAttributeNS('http://www.example.com/ns', 'test'), 'value'); + + const child = copy.firstChild!; + chai.assert.equal(child.nodeName, 'child'); + chai.assert.notEqual(child, element.firstChild); }); }); }); diff --git a/test/MutationObserver.tests.ts b/test/MutationObserver.tests.ts new file mode 100644 index 0000000..7872dea --- /dev/null +++ b/test/MutationObserver.tests.ts @@ -0,0 +1,489 @@ +import * as chai from 'chai'; +import * as lolex from 'lolex'; +import * as slimdom from '../src/index'; + +describe('MutationObserver', () => { + let clock: lolex.Clock; + before(() => { + clock = lolex.install(); + }); + + after(() => { + clock.uninstall(); + }); + + let document: slimdom.Document; + let observer: slimdom.MutationObserver; + let calls: { records: slimdom.MutationRecord[]; observer: slimdom.MutationObserver }[]; + let callbackCalled: boolean; + function callback(records: slimdom.MutationRecord[], observer: slimdom.MutationObserver) { + callbackCalled = true; + calls.push({ records, observer }); + } + + beforeEach(() => { + document = new slimdom.Document(); + observer = new slimdom.MutationObserver(callback); + calls = []; + callbackCalled = false; + }); + + afterEach(() => { + observer.disconnect(); + }); + + interface ExpectedRecord { + type?: string; + target?: slimdom.Node; + oldValue?: string | null; + attributeName?: string; + attributeNamespace?: string | null; + addedNodes?: slimdom.Node[]; + removedNodes?: slimdom.Node[]; + previousSibling?: slimdom.Node | null; + nextSibling?: slimdom.Node | null; + } + + function assertRecords(records: slimdom.MutationRecord[], expected: ExpectedRecord[]): void { + chai.assert.equal(records.length, expected.length); + expected.forEach((expectedRecord, i) => { + const actualRecord = records[i]; + Object.keys(expectedRecord).forEach(key => { + const expectedValue = (expectedRecord as any)[key]; + const actualValue = (actualRecord as any)[key]; + if (Array.isArray(expectedValue)) { + chai.assert.deepEqual(actualValue, expectedValue, `property ${key} of record ${i}`); + } else { + chai.assert.equal( + actualValue, + expectedValue, + `property ${key} of record ${i} is ${actualValue}, expected ${expectedValue}` + ); + } + }); + }); + } + + describe('.observe', () => { + it("throws if options doesn't specify the types of mutation to observe", () => { + const observer = new slimdom.MutationObserver(() => {}); + chai.assert.throws(() => observer.observe(document, {}), TypeError); + }); + + it('throws if asking for the old value of attributes without observing them', () => { + const observer = new slimdom.MutationObserver(() => {}); + chai.assert.throws( + () => observer.observe(document, { attributes: false, attributeOldValue: true, childList: true }), + TypeError + ); + }); + + it('throws if asking for the old value of character data without observing them', () => { + const observer = new slimdom.MutationObserver(() => {}); + chai.assert.throws( + () => observer.observe(document, { characterData: false, characterDataOldValue: true, childList: true }), + TypeError + ); + }); + }); + + type TestCase = (observer: slimdom.MutationObserver) => ExpectedRecord[] | null; + const cases: { [description: string]: TestCase } = { + 'responds to text changes': observer => { + const element = document.createElement('test'); + const text = element.appendChild(document.createTextNode('text')) as slimdom.Text; + observer.observe(element, { subtree: true, characterData: true }); + + text.data = 'meep'; + + return [{ type: 'characterData', oldValue: null, target: text }]; + }, + + 'records previous text values': observer => { + const element = document.createElement('test'); + const text = element.appendChild(document.createTextNode('text')) as slimdom.Text; + observer.observe(element, { subtree: true, characterDataOldValue: true }); + + text.data = 'meep'; + + return [{ type: 'characterData', oldValue: 'text', target: text }]; + }, + + 'responds to attribute changes': observer => { + const element = document.createElement('test'); + element.setAttribute('attr', 'value'); + observer.observe(element, { attributes: true }); + + // Even same-value changes generate records + element.setAttribute('attr', 'value'); + element.setAttributeNS('http://www.example.com/ns', 'prf:attr', 'value'); + + return [ + { + type: 'attributes', + target: element, + attributeName: 'attr', + attributeNamespace: null, + oldValue: null + }, + { + type: 'attributes', + target: element, + attributeName: 'attr', + attributeNamespace: 'http://www.example.com/ns', + oldValue: null + } + ]; + }, + + 'records previous attribute values': observer => { + const element = document.createElement('test'); + element.setAttribute('attr', 'value'); + observer.observe(element, { attributeOldValue: true }); + + // Even same-value changes generate records + element.setAttribute('attr', 'value'); + element.setAttributeNS('http://www.example.com/ns', 'prf:attr', 'value'); + + return [ + { + type: 'attributes', + target: element, + attributeName: 'attr', + attributeNamespace: null, + oldValue: 'value' + }, + { + type: 'attributes', + target: element, + attributeName: 'attr', + attributeNamespace: 'http://www.example.com/ns', + oldValue: null + } + ]; + }, + + 'responds to insertions (appendChild)': observer => { + const comment = document.appendChild(document.createComment('test')); + const element = document.createElement('child'); + observer.observe(document, { childList: true }); + + document.appendChild(element); + + return [ + { + type: 'childList', + target: document, + addedNodes: [element], + removedNodes: [], + previousSibling: comment, + nextSibling: null + } + ]; + }, + + 'responds to insertions (replaceChild)': observer => { + const parent = document.appendChild(document.createElement('parent')); + const oldChild = parent.appendChild(document.createElement('old')); + const newChild = document.createElement('new'); + observer.observe(document, { childList: true, subtree: true }); + parent.replaceChild(newChild, oldChild); + + return [ + { + type: 'childList', + target: parent, + addedNodes: [newChild], + removedNodes: [oldChild], + nextSibling: null, + previousSibling: null + } + ]; + }, + + 'responds to moves (insertBefore)': observer => { + const comment = document.appendChild(document.createComment('comment')); + const element = document.appendChild(document.createElement('element')); + const text = element.appendChild(document.createTextNode('text')); + observer.observe(document, { childList: true, subtree: true }); + + element.insertBefore(comment, text); + + return [ + { + type: 'childList', + target: document, + addedNodes: [], + removedNodes: [comment], + nextSibling: element, + previousSibling: null + }, + { + type: 'childList', + target: element, + addedNodes: [comment], + removedNodes: [], + nextSibling: text, + previousSibling: null + } + ]; + }, + + 'does not respond to attribute changes if the attributes option is not set': observer => { + const element = document.createElement('test'); + observer.observe(element, { attributes: false, childList: true }); + element.setAttribute('test', 'value'); + + return null; + }, + + 'does not respond to character data changes if the characterData option is not set': observer => { + const text = document.createTextNode('test'); + observer.observe(text, { childList: true, characterData: false }); + text.nodeValue = 'prrrt'; + + return null; + }, + + 'does not respond to childList changes if the childList option is not set': observer => { + const element = document.createElement('test'); + observer.observe(element, { attributes: true, childList: false }); + element.appendChild(document.createElement('child')); + + return null; + }, + + 'does not respond to subtree mutations if the subtree option is not set': observer => { + const element = document.appendChild(document.createElement('test')) as slimdom.Element; + observer.observe(document, { attributes: true, childList: true }); + element.appendChild(document.createElement('child')); + element.setAttribute('test', 'value'); + + return null; + }, + + 'only responds once to subtree mutations, even when observing multiple ancestors': observer => { + const element = document.appendChild(document.createElement('element')); + observer.observe(document, { childList: true, subtree: true }); + observer.observe(element, { childList: true, subtree: true }); + const comment = element.appendChild(document.createComment('test')); + + return [ + { + type: 'childList', + target: element, + addedNodes: [comment], + removedNodes: [], + previousSibling: null, + nextSibling: null + } + ]; + }, + + 'continues tracking under a removed node until javascript re-enters the event loop': observer => { + const parent = document.appendChild(document.createElement('parent')); + const child = parent.appendChild(document.createElement('child')); + const text = child.appendChild(document.createTextNode('text')) as slimdom.Text; + observer.observe(document, { childList: true, characterDataOldValue: true, subtree: true }); + document.removeChild(parent); + parent.removeChild(child); + text.data = 'test'; + + return [ + { + type: 'childList', + target: document, + removedNodes: [parent] + }, + { + type: 'childList', + target: parent, + removedNodes: [child] + }, + { + type: 'characterData', + target: text, + oldValue: 'text' + } + ]; + }, + + 'does not add transient registered observers for non-subtree observers': observer => { + const parent = document.appendChild(document.createElement('parent')); + const child = parent.appendChild(document.createElement('child')); + const text = child.appendChild(document.createTextNode('text')) as slimdom.Text; + observer.observe(document, { childList: true, characterDataOldValue: true, subtree: false }); + document.removeChild(parent); + parent.removeChild(child); + text.data = 'test'; + + return [ + { + type: 'childList', + target: document, + removedNodes: [parent] + } + ]; + }, + + 'removes transient observers when observe is called for the same observer': observer => { + const parent = document.appendChild(document.createElement('parent')); + const child = parent.appendChild(document.createElement('child')); + const text = child.appendChild(document.createTextNode('text')) as slimdom.Text; + observer.observe(document, { childList: true, characterDataOldValue: true, subtree: true }); + document.removeChild(parent); + observer.observe(document, { childList: true, characterDataOldValue: true, subtree: true }); + parent.removeChild(child); + text.data = 'test'; + + return [ + { + type: 'childList', + target: document, + removedNodes: [parent] + } + ]; + }, + + 'does not remove transient observers when observe is called for a different observer': observer => { + const parent = document.appendChild(document.createElement('parent')); + const child = parent.appendChild(document.createElement('child')); + const text = child.appendChild(document.createTextNode('text')) as slimdom.Text; + observer.observe(document, { childList: true, characterDataOldValue: true, subtree: true }); + const otherObserver = new slimdom.MutationObserver(callback); + otherObserver.observe(document, { childList: true, characterDataOldValue: true, subtree: true }); + document.removeChild(parent); + otherObserver.observe(document, { childList: true, characterDataOldValue: true, subtree: true }); + parent.removeChild(child); + text.data = 'test'; + + assertRecords(otherObserver.takeRecords(), [ + { + type: 'childList', + target: document, + removedNodes: [parent] + } + ]); + + return [ + { + type: 'childList', + target: document, + removedNodes: [parent] + }, + { + type: 'childList', + target: parent, + removedNodes: [child] + }, + { + type: 'characterData', + target: text, + oldValue: 'text' + } + ]; + }, + + 'does not remove transient observers when observe is called for a different subtree': observer => { + const parent = document.appendChild(document.createElement('parent')); + const child1 = parent.appendChild(document.createElement('child1')); + const child2 = parent.appendChild(document.createElement('child2')); + observer.observe(parent, { childList: true, subtree: true }); + observer.observe(child1, { childList: true, subtree: true }); + observer.observe(child2, { childList: true, subtree: true }); + parent.removeChild(child1); + observer.observe(child2, { childList: true, subtree: true }); + const comment1 = child1.appendChild(document.createComment('test')); + const comment2 = child2.appendChild(document.createComment('test')); + + return [ + { + type: 'childList', + target: parent, + removedNodes: [child1] + }, + { + type: 'childList', + target: child1, + addedNodes: [comment1] + }, + { + type: 'childList', + target: child2, + addedNodes: [comment2] + } + ]; + }, + + 'does not observe after being disconnected': observer => { + observer.observe(document, { childList: true }); + observer.disconnect(); + document.appendChild(document.createComment('test')); + + return null; + }, + + 'does not affect other observers when disconnected': observer => { + const otherObserver = new slimdom.MutationObserver(callback); + otherObserver.observe(document, { childList: true, subtree: true }); + observer.observe(document, { childList: true }); + observer.disconnect(); + const comment = document.appendChild(document.createComment('test')); + + assertRecords(otherObserver.takeRecords(), [ + { + type: 'childList', + target: document, + addedNodes: [comment] + } + ]); + + return null; + } + }; + + describe('synchronous usage', () => { + Object.keys(cases).forEach(description => { + const testCase = cases[description]; + it(description, () => { + const expected = testCase(observer) || []; + + const records = observer.takeRecords(); + assertRecords(records, expected); + + clock.tick(100); + + chai.assert(!callbackCalled, 'callback was not called'); + }); + }); + }); + + describe('asynchronous usage', () => { + let observer: slimdom.MutationObserver; + beforeEach(() => { + observer = new slimdom.MutationObserver(callback); + }); + + afterEach(() => { + observer.disconnect(); + }); + + Object.keys(cases).forEach(description => { + const testCase = cases[description]; + it(description, () => { + const expected = testCase(observer); + + clock.tick(100); + + if (expected !== null) { + chai.assert(callbackCalled, 'callback was called'); + chai.assert.equal(calls.length, 1); + chai.assert.equal(calls[0].observer, observer); + assertRecords(calls[0].records, expected); + } else { + chai.assert(!callbackCalled, 'callback was not called'); + } + }); + }); + }); +}); diff --git a/test/ProcessingInstruction.tests.ts b/test/ProcessingInstruction.tests.ts index 2e7b313..50fc100 100644 --- a/test/ProcessingInstruction.tests.ts +++ b/test/ProcessingInstruction.tests.ts @@ -1,35 +1,31 @@ -import slimdom from '../src/index'; - -import Document from '../src/Document'; -import ProcessingInstruction from '../src/ProcessingInstruction'; - import * as chai from 'chai'; +import * as slimdom from '../src/index'; describe('ProcessingInstruction', () => { - let document: Document; - let processingInstruction: ProcessingInstruction; + let document: slimdom.Document; beforeEach(() => { - document = slimdom.createDocument(); - processingInstruction = document.createProcessingInstruction('sometarget', 'somedata'); - }); - - it('has nodeType 7', () => chai.assert.equal(processingInstruction.nodeType, 7)); - - it('has data', () => { - chai.assert.equal(processingInstruction.nodeValue, 'somedata'); - chai.assert.equal(processingInstruction.data, 'somedata'); + document = new slimdom.Document(); }); - it('has a target', () => { - chai.assert.equal(processingInstruction.target, 'sometarget'); + it('can be created using Document#createProcessingInstruction()', () => { + const pi = document.createProcessingInstruction('sometarget', 'some data'); + chai.assert.equal(pi.nodeType, 7); + chai.assert.equal(pi.nodeName, 'sometarget'); + chai.assert.equal(pi.nodeValue, 'some data'); + chai.assert.equal(pi.target, 'sometarget'); + chai.assert.equal(pi.data, 'some data'); + chai.assert.equal(pi.ownerDocument, document); }); it('can be cloned', () => { - var clone = processingInstruction.cloneNode(true) as ProcessingInstruction; - chai.assert.equal(clone.nodeType, 7); - chai.assert.equal(clone.nodeValue, 'somedata'); - chai.assert.equal(clone.data, 'somedata'); - chai.assert.equal(clone.target, 'sometarget'); - chai.assert.notEqual(clone, processingInstruction); + const pi = document.createProcessingInstruction('sometarget', 'some data'); + var copy = pi.cloneNode() as slimdom.ProcessingInstruction; + chai.assert.equal(copy.nodeType, 7); + chai.assert.equal(copy.nodeName, 'sometarget'); + chai.assert.equal(copy.nodeValue, 'some data'); + chai.assert.equal(copy.target, 'sometarget'); + chai.assert.equal(copy.data, 'some data'); + chai.assert.equal(copy.ownerDocument, document); + chai.assert.notEqual(copy, pi); }); }); diff --git a/test/selections/Range.tests.ts b/test/Range.tests.ts similarity index 52% rename from test/selections/Range.tests.ts rename to test/Range.tests.ts index 9374d5f..6d75b26 100644 --- a/test/selections/Range.tests.ts +++ b/test/Range.tests.ts @@ -1,22 +1,15 @@ -import slimdom from '../../src/index'; - -import Document from '../../src/Document'; -import Element from '../../src/Element'; -import Node from '../../src/Node'; -import Text from '../../src/Text'; -import Range from '../../src/selections/Range'; - import * as chai from 'chai'; +import * as slimdom from '../src/index'; describe('Range', () => { - let document: Document; - let element: Element; - let text: Text; - let range: Range; + let document: slimdom.Document; + let element: slimdom.Element; + let text: slimdom.Text; + let range: slimdom.Range; beforeEach(() => { - document = slimdom.createDocument(); - element = document.appendChild(document.createElement('root')) as Element; - text = element.appendChild(document.createTextNode('text')) as Text; + document = new slimdom.Document(); + element = document.appendChild(document.createElement('root')) as slimdom.Element; + text = element.appendChild(document.createTextNode('text')) as slimdom.Text; range = document.createRange(); }); @@ -26,6 +19,7 @@ describe('Range', () => { chai.assert.equal(range.endContainer, document); chai.assert.equal(range.startOffset, 0); chai.assert.equal(range.endOffset, 0); + chai.assert.equal(range.commonAncestorContainer, document); }); describe('setting positions', () => { @@ -36,6 +30,7 @@ describe('Range', () => { chai.assert.equal(range.endContainer, element); chai.assert.equal(range.endOffset, 0); chai.assert.equal(range.collapsed, true); + chai.assert.equal(range.commonAncestorContainer, element); }); it('end after start is ok', () => { @@ -45,6 +40,52 @@ describe('Range', () => { chai.assert.equal(range.startContainer, document); chai.assert.equal(range.startOffset, 0); chai.assert.equal(range.collapsed, false); + chai.assert.equal(range.commonAncestorContainer, document); + }); + + it('end before start moves start', () => { + range.setStart(element, 1); + range.setEnd(element, 0); + chai.assert.equal(range.endContainer, element); + chai.assert.equal(range.endOffset, 0); + chai.assert.equal(range.startContainer, element); + chai.assert.equal(range.startOffset, 0); + chai.assert.equal(range.collapsed, true); + chai.assert.equal(range.commonAncestorContainer, element); + }); + + it('throws if the container is a doctype', () => { + const doctype = document.implementation.createDocumentType('html', '', ''); + chai.assert.throws(() => range.setStart(doctype, 0), 'InvalidNodeTypeError'); + chai.assert.throws(() => range.setEnd(doctype, 0), 'InvalidNodeTypeError'); + }); + + it('throws if the index is beyond the length of the node', () => { + chai.assert.throws(() => range.setStart(text, 5), 'IndexSizeError'); + chai.assert.throws(() => range.setEnd(text, -1), 'IndexSizeError'); + }); + + it('can set its endpoints relative to a node', () => { + range.setStartBefore(element); + range.setEndBefore(text); + chai.assert.equal(range.startContainer, document); + chai.assert.equal(range.startOffset, 0); + chai.assert.equal(range.endContainer, element); + chai.assert.equal(range.endOffset, 0); + range.setStartAfter(text); + range.setEndAfter(element); + chai.assert.equal(range.startContainer, element); + chai.assert.equal(range.startOffset, 1); + chai.assert.equal(range.endContainer, document); + chai.assert.equal(range.endOffset, 1); + }); + + it('can not set an endpoint before or after a node without a parent', () => { + const detached = document.createElement('noparent'); + chai.assert.throws(() => range.setStartBefore(detached), 'InvalidNodeTypeError'); + chai.assert.throws(() => range.setStartAfter(detached), 'InvalidNodeTypeError'); + chai.assert.throws(() => range.setEndBefore(detached), 'InvalidNodeTypeError'); + chai.assert.throws(() => range.setEndAfter(detached), 'InvalidNodeTypeError'); }); it('can selectNode', () => { @@ -54,6 +95,12 @@ describe('Range', () => { chai.assert.equal(range.endContainer, document); chai.assert.equal(range.endOffset, 1); chai.assert.equal(range.collapsed, false); + chai.assert.equal(range.commonAncestorContainer, document); + }); + + it('can not selectNode a node without a parent', () => { + const detached = document.createElement('noparent'); + chai.assert.throws(() => range.selectNode(detached), 'InvalidNodeTypeError'); }); it('can selectNodeContents', () => { @@ -63,6 +110,12 @@ describe('Range', () => { chai.assert.equal(range.endContainer, element); chai.assert.equal(range.endOffset, 1); chai.assert.equal(range.collapsed, false); + chai.assert.equal(range.commonAncestorContainer, element); + }); + + it('can not selectNodeContents a doctype', () => { + const doctype = document.implementation.createDocumentType('html', '', ''); + chai.assert.throws(() => range.selectNodeContents(doctype), 'InvalidNodeTypeError'); }); it('can be collapsed to start', () => { @@ -77,7 +130,7 @@ describe('Range', () => { it('can be collapsed to end', () => { range.selectNodeContents(element); - range.collapse(false); + range.collapse(); chai.assert.equal(range.startContainer, element); chai.assert.equal(range.startOffset, 1); chai.assert.equal(range.endContainer, element); @@ -85,21 +138,89 @@ describe('Range', () => { chai.assert.equal(range.collapsed, true); }); - it('can be cloned', () => { - range.selectNodeContents(element); - const clone = range.cloneRange(); - range.setStart(document, 0); - range.collapse(true); - chai.assert.equal(clone.startContainer, element); - chai.assert.equal(clone.startOffset, 0); - chai.assert.equal(clone.endContainer, element); - chai.assert.equal(clone.endOffset, 1); - chai.assert.equal(clone.collapsed, false); + it('can compute the common ancestor', () => { + const child = element.appendChild(document.createElement('child')).appendChild(document.createTextNode('test')); + range.setStart(text, 0); + range.setEnd(child, 0); + chai.assert.equal(range.commonAncestorContainer, element); }); }); - describe('under mutations', () => { + it('can be cloned', () => { + range.selectNodeContents(element); + const clone = range.cloneRange(); + range.setStart(document, 0); + range.collapse(true); + chai.assert.equal(clone.startContainer, element); + chai.assert.equal(clone.startOffset, 0); + chai.assert.equal(clone.endContainer, element); + chai.assert.equal(clone.endOffset, 1); + chai.assert.equal(clone.collapsed, false); + }); + + describe('comparing points', () => { + it('can compare boundary points agains another range', () => { + const range2 = document.createRange(); + range.setStart(element, 0); + range.setEnd(text, 2); + range2.setStart(text, 2); + range2.setEnd(document, 1); + chai.assert.throws(() => range.compareBoundaryPoints(98, range2), 'NotSupportedError'); + + chai.assert.equal(range.compareBoundaryPoints(slimdom.Range.START_TO_START, range2), -1); + chai.assert.equal(range.compareBoundaryPoints(slimdom.Range.START_TO_END, range2), 0); + chai.assert.equal(range2.compareBoundaryPoints(slimdom.Range.END_TO_END, range), 1); + chai.assert.equal(range.compareBoundaryPoints(slimdom.Range.END_TO_START, range2), -1); + range2.detach(); + }); + + it('can not compare boundary points if the ranges are in different documents', () => { + const range2 = new slimdom.Range(); + chai.assert.throws(() => range.compareBoundaryPoints(slimdom.Range.START_TO_START, range2)); + range2.detach(); + range2.detach(); + }); + + it('can compare a given point to the range', () => { + range.setStart(element, 0); + range.setEnd(element, 1); + chai.assert(range.isPointInRange(text, 1)); + chai.assert(!range.isPointInRange(document, 1)); + + const range2 = new slimdom.Range(); + const doctype = document.implementation.createDocumentType('html', '', ''); + document.insertBefore(doctype, document.documentElement); + + chai.assert(!range2.isPointInRange(element, 0)); + chai.assert.throws(() => range.isPointInRange(doctype, 0), 'InvalidNodeTypeError'); + chai.assert.throws(() => range.isPointInRange(element, 3), 'IndexSizeError'); + + chai.assert.equal(range.comparePoint(element, 0), 0); + chai.assert.equal(range.comparePoint(document, 1), -1); + chai.assert.equal(range.comparePoint(document, 2), 1); + + chai.assert.throws(() => range2.comparePoint(element, 0), 'WrongDocumentError'); + chai.assert.throws(() => range.comparePoint(doctype, 0), 'InvalidNodeTypeError'); + chai.assert.throws(() => range.comparePoint(element, 3), 'IndexSizeError'); + }); + + it('can compare a given node to the range', () => { + range.setStart(text, 0); + range.setEnd(element, 1); + const child = element.appendChild(document.createElement('child')); + + chai.assert(range.intersectsNode(text), 'intersects text'); + chai.assert(range.intersectsNode(element), 'intersects element'); + chai.assert(range.intersectsNode(document), 'intersects the document'); + chai.assert(!range.intersectsNode(child), 'does not intersect child'); + + const range2 = new slimdom.Range(); + chai.assert(!range2.intersectsNode(document), "different roots don't intersect"); + }); + }); + + describe('under mutations', () => { describe('in element', () => { beforeEach(() => { range.setStart(element, 0); @@ -115,7 +236,7 @@ describe('Range', () => { }); it('moves positions beyond a remove', () => { - element.removeChild(element.firstChild as Node); + element.removeChild(element.firstChild!); chai.assert.equal(range.startContainer, element); chai.assert.equal(range.startOffset, 0); chai.assert.equal(range.endContainer, element); @@ -214,7 +335,7 @@ describe('Range', () => { it('moves with text node deletes during normalization', () => { text.deleteData(0, 4); - element.normalize(true); + element.normalize(); chai.assert.equal(range.startContainer, element); chai.assert.equal(range.startOffset, 0); chai.assert.equal(range.endContainer, element); @@ -222,10 +343,10 @@ describe('Range', () => { }); it('moves with text node merges during normalization', () => { - const otherText = element.appendChild(document.createTextNode('more')) as Node; + const otherText = element.appendChild(document.createTextNode('more')); range.setStartBefore(otherText); range.setEnd(otherText, 2); - element.normalize(true); + element.normalize(); chai.assert.equal(range.startContainer, text); chai.assert.equal(range.startOffset, 4); chai.assert.equal(range.endContainer, text); diff --git a/test/Text.tests.ts b/test/Text.tests.ts index 19c81f3..dcba3d2 100644 --- a/test/Text.tests.ts +++ b/test/Text.tests.ts @@ -1,57 +1,94 @@ -import slimdom from '../src/index'; - -import Document from '../src/Document'; -import Element from '../src/Element'; -import Text from '../src/Text'; - import * as chai from 'chai'; +import * as slimdom from '../src/index'; describe('Text', () => { - let document: Document; - let text: Text; + let document: slimdom.Document; beforeEach(() => { - document = slimdom.createDocument(); - text = document.createTextNode('text'); + document = new slimdom.Document(); + }); + + it('can be created using Document#createTextNode()', () => { + const text = document.createTextNode('some data'); + chai.assert.equal(text.nodeType, 3); + chai.assert.equal(text.nodeName, '#text'); + chai.assert.equal(text.nodeValue, 'some data'); + chai.assert.equal(text.data, 'some data'); + + chai.assert.equal(text.ownerDocument, document); }); - it('has nodeType 3', () => chai.assert.equal(text.nodeType, 3)); + it('can be created using its constructor (with data)', () => { + const text = new slimdom.Text('some data'); + chai.assert.equal(text.nodeType, 3); + chai.assert.equal(text.nodeName, '#text'); + chai.assert.equal(text.nodeValue, 'some data'); + chai.assert.equal(text.data, 'some data'); + + chai.assert.equal(text.ownerDocument, slimdom.document); + }); - it('has data', () => chai.assert.equal(text.data, 'text')); + it('can be created using its constructor (without arguments)', () => { + const text = new slimdom.Text(); + chai.assert.equal(text.nodeType, 3); + chai.assert.equal(text.nodeName, '#text'); + chai.assert.equal(text.nodeValue, ''); + chai.assert.equal(text.data, ''); - it('has a nodeValue', () => chai.assert.equal(text.nodeValue, 'text')); + chai.assert.equal(text.ownerDocument, slimdom.document); + }); - it('has a length', () => chai.assert.equal(text.length, 4)); + it('can set its data using nodeValue', () => { + const text = document.createTextNode('some data'); + text.nodeValue = 'other data'; + chai.assert.equal(text.nodeValue, 'other data'); + chai.assert.equal(text.data, 'other data'); - it('can set data property', () => { - var newValue = 'a new text value'; - text.data = newValue; - chai.assert.equal(text.data, newValue); - chai.assert.equal(text.nodeValue, newValue); - chai.assert.equal(text.length, newValue.length); + text.nodeValue = null; + chai.assert.equal(text.nodeValue, ''); + chai.assert.equal(text.data, ''); }); - // TODO: wholeText not yet supported - it('has wholeText'); - //it('has wholeText', () => { - // chai.assert.equal(text.wholeText, 'text'); - //}) + it('can set its data using data', () => { + const text = document.createTextNode('some data'); + text.data = 'other data'; + chai.assert.equal(text.nodeValue, 'other data'); + chai.assert.equal(text.data, 'other data'); + }); it('can be cloned', () => { - var clone = text.cloneNode(true) as Text; - chai.assert.equal(clone.nodeType, 3); - chai.assert.equal(clone.nodeValue, 'text'); - chai.assert.equal(clone.data, 'text'); - chai.assert.notEqual(clone, text); + const text = document.createTextNode('some data'); + var copy = text.cloneNode() as slimdom.Text; + chai.assert.equal(copy.nodeType, 3); + chai.assert.equal(copy.nodeName, '#text'); + chai.assert.equal(copy.nodeValue, 'some data'); + chai.assert.equal(copy.data, 'some data'); + chai.assert.notEqual(copy, text); + }); + + it('can lookup a prefix or namespace on its parent element', () => { + const text = document.createTextNode('some data'); + chai.assert.equal(text.lookupNamespaceURI('prf'), null); + chai.assert.equal(text.lookupPrefix('http://www.example.com/ns'), null); + + const element = document.createElementNS('http://www.example.com/ns', 'prf:test'); + element.appendChild(text); + chai.assert.equal(text.lookupNamespaceURI('prf'), 'http://www.example.com/ns'); + chai.assert.equal(text.lookupPrefix('http://www.example.com/ns'), 'prf'); }); it('can substring its data', () => { + const text = document.createTextNode('text'); chai.assert.equal(text.substringData(0, 2), 'te'); chai.assert.equal(text.substringData(2, 2), 'xt'); chai.assert.equal(text.substringData(1, 2), 'ex'); - chai.assert.equal(text.substringData(2), 'xt'); + chai.assert.equal(text.substringData(2, 9999), 'xt'); + + chai.assert.throws(() => text.substringData(-123, 1), 'IndexSizeError'); + chai.assert.throws(() => text.substringData(123, 1), 'IndexSizeError'); }); it('can appendData', () => { + const text = document.createTextNode('text'); text.appendData('123'); chai.assert.equal(text.data, 'text123'); chai.assert.equal(text.nodeValue, text.data); @@ -59,90 +96,107 @@ describe('Text', () => { }); it('can insertData', () => { + const text = document.createTextNode('text'); text.insertData(2, '123'); chai.assert.equal(text.data, 'te123xt'); chai.assert.equal(text.nodeValue, text.data); chai.assert.equal(text.length, 7); - text.insertData(-100, '123'); + text.insertData(0, '123'); chai.assert.equal(text.data, '123te123xt'); chai.assert.equal(text.nodeValue, text.data); chai.assert.equal(text.length, 10); - text.insertData(100, '123'); + text.insertData(text.length, '123'); chai.assert.equal(text.data, '123te123xt123'); chai.assert.equal(text.nodeValue, text.data); chai.assert.equal(text.length, 13); + + chai.assert.throws(() => text.insertData(-123, '123'), 'IndexSizeError'); + chai.assert.throws(() => text.insertData(123, '123'), 'IndexSizeError'); }); it('can deleteData', () => { + const text = document.createTextNode('text'); text.deleteData(0, 0); chai.assert.equal(text.data, 'text'); chai.assert.equal(text.nodeValue, text.data); chai.assert.equal(text.length, 4); - text.deleteData(-100, 1); - chai.assert.equal(text.data, 'text'); + text.deleteData(0, 1); + chai.assert.equal(text.data, 'ext'); chai.assert.equal(text.nodeValue, text.data); - chai.assert.equal(text.length, 4); + chai.assert.equal(text.length, 3); - text.deleteData(100, 2); - chai.assert.equal(text.data, 'text'); + text.deleteData(text.length, 2); + chai.assert.equal(text.data, 'ext'); chai.assert.equal(text.nodeValue, text.data); - chai.assert.equal(text.length, 4); + chai.assert.equal(text.length, 3); text.deleteData(1, 1); - chai.assert.equal(text.data, 'txt'); + chai.assert.equal(text.data, 'et'); chai.assert.equal(text.nodeValue, text.data); - chai.assert.equal(text.length, 3); + chai.assert.equal(text.length, 2); - text.deleteData(2); - chai.assert.equal(text.data, 'tx'); + text.deleteData(1, 9999); + chai.assert.equal(text.data, 'e'); chai.assert.equal(text.nodeValue, text.data); - chai.assert.equal(text.length, 2); + chai.assert.equal(text.length, 1); + + chai.assert.throws(() => text.deleteData(-123, 2), 'IndexSizeError'); + chai.assert.throws(() => text.deleteData(123, 2), 'IndexSizeError'); }); it('can replaceData', () => { + const text = document.createTextNode('text'); text.replaceData(0, 0, ''); chai.assert.equal(text.data, 'text'); chai.assert.equal(text.nodeValue, text.data); chai.assert.equal(text.length, 4); - text.replaceData(-100, 10, 'asd'); - chai.assert.equal(text.data, 'asdtext'); + text.replaceData(0, 10, 'asd'); + chai.assert.equal(text.data, 'asd'); chai.assert.equal(text.nodeValue, text.data); - chai.assert.equal(text.length, 7); + chai.assert.equal(text.length, 3); - text.replaceData(100, 10, 'asd'); - chai.assert.equal(text.data, 'asdtextasd'); + text.replaceData(text.length, 10, 'fgh'); + chai.assert.equal(text.data, 'asdfgh'); chai.assert.equal(text.nodeValue, text.data); - chai.assert.equal(text.length, 10); + chai.assert.equal(text.length, 6); text.replaceData(3, 4, 'asd'); - chai.assert.equal(text.data, 'asdasdasd'); + chai.assert.equal(text.data, 'asdasd'); chai.assert.equal(text.nodeValue, text.data); - chai.assert.equal(text.length, 9); + chai.assert.equal(text.length, 6); + + chai.assert.throws(() => text.replaceData(-123, 2, 'text'), 'IndexSizeError'); + chai.assert.throws(() => text.replaceData(123, 2, 'text'), 'IndexSizeError'); }); describe('splitting', () => { it('can be split', () => { + const text = document.createTextNode('text'); const otherHalf = text.splitText(2); chai.assert.equal(text.data, 'te'); chai.assert.equal(text.nodeValue, text.data); chai.assert.equal(otherHalf.data, 'xt'); chai.assert.equal(otherHalf.nodeValue, otherHalf.data); + + chai.assert.throws(() => text.splitText(-123), 'IndexSizeError'); + chai.assert.throws(() => text.splitText(123), 'IndexSizeError'); }); - + describe('under a parent', () => { - let element: Element; - let otherHalf: Text; + let text: slimdom.Text; + let element: slimdom.Element; beforeEach(() => { element = document.createElement('parent'); + text = document.createTextNode('text'); element.appendChild(text); - otherHalf = text.splitText(2); }); it('is split correctly', () => { + const otherHalf = text.splitText(2); chai.assert.equal(text.data, 'te'); chai.assert.equal(text.nodeValue, text.data); chai.assert.equal(otherHalf.data, 'xt'); @@ -150,21 +204,34 @@ describe('Text', () => { }); it('both halves are children of the parent', () => { + const otherHalf = text.splitText(2); chai.assert.equal(text.parentNode, element); chai.assert.equal(otherHalf.parentNode, element); }); it('both halves are siblings', () => { + const otherHalf = text.splitText(2); chai.assert.equal(text.nextSibling, otherHalf); chai.assert.equal(otherHalf.previousSibling, text); }); - // TODO: wholeText not yet supported - it('has wholeText'); - //it('has wholeText', () => { - // chai.assert.equal(text.wholeText, 'text'); - // chai.assert.equal(otherHalf.wholeText, 'text'); - //}); + it('updates ranges after the split point', () => { + const range1 = new slimdom.Range(); + const range2 = new slimdom.Range(); + range1.setStart(text, 3); + range1.setEnd(text, 4); + range2.setStart(element, 1); + range2.collapse(true); + const otherHalf = text.splitText(2); + chai.assert.equal(range1.startContainer, otherHalf); + chai.assert.equal(range1.startOffset, 1); + chai.assert.equal(range1.endContainer, otherHalf); + chai.assert.equal(range1.endOffset, 2); + chai.assert.equal(range2.startContainer, element); + chai.assert.equal(range2.startOffset, 2); + chai.assert.equal(range2.endContainer, element); + chai.assert.equal(range2.endOffset, 2); + }); }); }); }); diff --git a/test/XMLDocument.tests.ts b/test/XMLDocument.tests.ts new file mode 100644 index 0000000..ca8d041 --- /dev/null +++ b/test/XMLDocument.tests.ts @@ -0,0 +1,17 @@ +import * as chai from 'chai'; +import * as slimdom from '../src/index'; + +describe('XMLDocument', () => { + it('can be created using DOMImplementation#createDocument()', () => { + const doc = slimdom.document.implementation.createDocument(null, ''); + chai.assert.instanceOf(doc, slimdom.XMLDocument); + chai.assert.equal(doc.nodeType, 9); + }); + + it('can be cloned', () => { + const doc = slimdom.document.implementation.createDocument(null, ''); + const copy = doc.cloneNode(); + chai.assert.instanceOf(copy, slimdom.XMLDocument); + chai.assert.equal(copy.nodeType, 9); + }); +}); diff --git a/test/mutationAlgorithms.tests.ts b/test/mutationAlgorithms.tests.ts new file mode 100644 index 0000000..9a58765 --- /dev/null +++ b/test/mutationAlgorithms.tests.ts @@ -0,0 +1,303 @@ +import * as chai from 'chai'; +import * as slimdom from '../src/index'; + +describe('DOM mutations', () => { + let document: slimdom.Document; + beforeEach(() => { + document = new slimdom.Document(); + }); + + describe('Node#appendChild / Node#insertBefore', () => { + it('throws if inserting a node below one that can not have children', () => { + const text = document.createTextNode('test'); + const comment = document.createComment('test'); + const pi = document.createProcessingInstruction('test', 'test'); + chai.assert.throws(() => text.appendChild(comment), 'HierarchyRequestError'); + chai.assert.throws(() => comment.appendChild(pi), 'HierarchyRequestError'); + chai.assert.throws(() => pi.appendChild(text), 'HierarchyRequestError'); + }); + + it('throws if inserting a node below one of its descendants', () => { + const descendant = document + .appendChild(document.createElement('ancestor')) + .appendChild(document.createElement('middle')) + .appendChild(document.createElement('descendant')); + chai.assert.throws(() => descendant.appendChild(document.documentElement!), 'HierarchyRequestError'); + }); + + it('throws if the reference node is not a child of the parent', () => { + const parent = document.createElement('parent'); + const notChild = document.createElement('notChild'); + const text = document.createTextNode('test'); + chai.assert.throws(() => parent.insertBefore(text, notChild), 'NotFoundError'); + }); + + it('throws if inserting a node that can not be a child', () => { + const attr = document.createAttribute('test'); + const doc = new slimdom.Document(); + const element = document.createElement('test'); + chai.assert.throws(() => element.appendChild(attr), 'HierarchyRequestError'); + chai.assert.throws(() => element.appendChild(doc), 'HierarchyRequestError'); + }); + + it('throws if inserting a text node directly under the document', () => { + const text = document.createTextNode('test'); + chai.assert.throws(() => document.appendChild(text), 'HierarchyRequestError'); + }); + + it('throws if inserting a doctype under something other than a document', () => { + const doctype = document.implementation.createDocumentType('html', '', ''); + const fragment = document.createDocumentFragment(); + chai.assert.throws(() => fragment.appendChild(doctype), 'HierarchyRequestError'); + }); + + it('throws if inserting a fragment would add a text node under a document', () => { + const fragment = document.createDocumentFragment(); + fragment.appendChild(document.createTextNode('test')); + chai.assert.throws(() => document.appendChild(fragment), 'HierarchyRequestError'); + }); + + it('throws if inserting a fragment would add multiple document elements', () => { + const fragment = document.createDocumentFragment(); + fragment.appendChild(document.createElement('child1')); + fragment.appendChild(document.createElement('child2')); + chai.assert.throws(() => document.appendChild(fragment), 'HierarchyRequestError'); + }); + + it('throws if inserting a fragment would add another document element', () => { + const fragment = document.createDocumentFragment(); + document.appendChild(document.createElement('child1')); + fragment.appendChild(document.createElement('child2')); + chai.assert.throws(() => document.appendChild(fragment), 'HierarchyRequestError'); + }); + + it('throws if inserting a fragment would add a document element before the doctype', () => { + const fragment = document.createDocumentFragment(); + fragment.appendChild(document.createElement('child1')); + const doctype = document.appendChild(document.implementation.createDocumentType('html', '', '')); + chai.assert.throws(() => document.insertBefore(fragment, doctype), 'HierarchyRequestError'); + const comment = document.insertBefore(document.createComment('test'), doctype); + chai.assert.throws(() => document.insertBefore(fragment, comment), 'HierarchyRequestError'); + }); + + it('allows inserting a document element using a fragment', () => { + const fragment = document.createDocumentFragment(); + const child = fragment.appendChild(document.createElement('child1')) as slimdom.Element; + const doctype = document.appendChild(document.implementation.createDocumentType('html', '', '')); + document.appendChild(fragment); + chai.assert.equal(document.documentElement, child); + }); + + it('throws if inserting a document element before the doctype', () => { + const element = document.createElement('test'); + const doctype = document.appendChild(document.implementation.createDocumentType('html', '', '')); + chai.assert.throws(() => document.insertBefore(element, doctype), 'HierarchyRequestError'); + const comment = document.insertBefore(document.createComment('test'), doctype); + chai.assert.throws(() => document.insertBefore(element, comment), 'HierarchyRequestError'); + }); + + it('throws if inserting a second doctype', () => { + const htmlDocument = document.implementation.createHTMLDocument('test'); + const doctype = document.implementation.createDocumentType('test', '', ''); + chai.assert.throws(() => htmlDocument.appendChild(doctype), 'HierarchyRequestError'); + }); + + it('throws if inserting a doctype after the document element', () => { + const element = document.appendChild(document.createElement('test')); + const doctype = document.implementation.createDocumentType('html', '', ''); + chai.assert.throws(() => document.appendChild(doctype), 'HierarchyRequestError'); + }); + + it('correctly handles inserting a node before itself', () => { + const parent = document.appendChild(document.createElement('parent')) as slimdom.Element; + const element = parent.appendChild(document.createElement('child')) as slimdom.Element; + parent.insertBefore(element, element); + chai.assert.equal(parent.firstElementChild, element); + chai.assert.equal(element.nextElementSibling, null); + chai.assert.equal(element.previousElementSibling, null); + }); + + it('throws if inserting the document element before itself', () => { + const element = document.appendChild(document.createElement('test')) as slimdom.Element; + chai.assert.throws(() => document.insertBefore(element, element), 'HierarchyRequestError'); + }); + + describe('effect on ranges', () => { + let range: slimdom.Range; + beforeEach(() => { + range = document.createRange(); + }); + + it('updates ranges after the insertion point', () => { + const parent = document.createElement('parent'); + const child = parent.appendChild(document.createComment('test')); + range.setStartAfter(child); + range.collapse(true); + chai.assert.equal(range.startContainer, parent); + chai.assert.equal(range.startOffset, 1); + parent.insertBefore(document.createTextNode('test'), child); + chai.assert.equal(range.startContainer, parent); + chai.assert.equal(range.startOffset, 2); + }); + }); + }); + + describe('replaceChild', () => { + it('throws if replacing under a non-parent node', () => { + const doctype = document.implementation.createDocumentType('html', '', ''); + chai.assert.throws(() => doctype.replaceChild(doctype, doctype), 'HierarchyRequestError'); + }); + + it('throws if inserting a node below one of its descendants', () => { + const descendant = document + .appendChild(document.createElement('ancestor')) + .appendChild(document.createElement('middle')) + .appendChild(document.createElement('descendant')); + const pi = descendant.appendChild(document.createProcessingInstruction('target', 'test')); + chai.assert.throws(() => descendant.replaceChild(document.documentElement!, pi), 'HierarchyRequestError'); + }); + + it('throws if replacing a node that is not a child of the parent', () => { + const parent = document.createElement('parent'); + const notChild = document.createElement('notChild'); + const text = document.createTextNode('test'); + chai.assert.throws(() => parent.replaceChild(text, notChild), 'NotFoundError'); + }); + + it('throws if inserting a node that can not be a child', () => { + const parent = document.createElement('parent'); + const oldChild = parent.appendChild(document.createComment('')); + const attr = document.createAttribute('test'); + const doc = new slimdom.Document(); + chai.assert.throws(() => parent.replaceChild(attr, oldChild), 'HierarchyRequestError'); + chai.assert.throws(() => parent.replaceChild(doc, oldChild), 'HierarchyRequestError'); + }); + + it('throws if inserting a text node directly under the document', () => { + const text = document.createTextNode('test'); + const oldChild = document.appendChild(document.createComment('')); + chai.assert.throws(() => document.replaceChild(text, oldChild), 'HierarchyRequestError'); + }); + + it('throws if inserting a doctype under something other than a document', () => { + const doctype = document.implementation.createDocumentType('html', '', ''); + const fragment = document.createDocumentFragment(); + const oldChild = fragment.appendChild(document.createComment('')); + chai.assert.throws(() => fragment.replaceChild(doctype, oldChild), 'HierarchyRequestError'); + }); + + it('throws if inserting a fragment would add a text node under a document', () => { + const oldChild = document.appendChild(document.createComment('')); + const fragment = document.createDocumentFragment(); + fragment.appendChild(document.createTextNode('test')); + chai.assert.throws(() => document.replaceChild(fragment, oldChild), 'HierarchyRequestError'); + }); + + it('throws if inserting a fragment would add multiple document elements', () => { + const oldChild = document.appendChild(document.createComment('')); + const fragment = document.createDocumentFragment(); + fragment.appendChild(document.createElement('child1')); + fragment.appendChild(document.createElement('child2')); + chai.assert.throws(() => document.replaceChild(fragment, oldChild), 'HierarchyRequestError'); + }); + + it('throws if inserting a fragment would add another document element', () => { + const oldChild = document.appendChild(document.createComment('')); + const fragment = document.createDocumentFragment(); + document.appendChild(document.createElement('child1')); + fragment.appendChild(document.createElement('child2')); + chai.assert.throws(() => document.replaceChild(fragment, oldChild), 'HierarchyRequestError'); + }); + + it('throws if inserting a fragment would add a document element before the doctype', () => { + const oldChild = document.appendChild(document.createComment('')); + const fragment = document.createDocumentFragment(); + fragment.appendChild(document.createElement('child1')); + const doctype = document.appendChild(document.implementation.createDocumentType('html', '', '')); + chai.assert.throws(() => document.replaceChild(fragment, oldChild), 'HierarchyRequestError'); + }); + + it('allows inserting a document element using a fragment', () => { + const fragment = document.createDocumentFragment(); + const child = fragment.appendChild(document.createElement('child1')) as slimdom.Element; + const doctype = document.appendChild(document.implementation.createDocumentType('html', '', '')); + const oldChild = document.appendChild(document.createComment('')); + document.replaceChild(fragment, oldChild); + chai.assert.equal(document.documentElement, child); + }); + + it('throws if inserting a document element before the doctype', () => { + const element = document.createElement('test'); + const oldChild = document.appendChild(document.createComment('')); + const doctype = document.appendChild(document.implementation.createDocumentType('html', '', '')); + chai.assert.throws(() => document.replaceChild(element, oldChild), 'HierarchyRequestError'); + }); + + it('throws if inserting a second doctype', () => { + const htmlDocument = document.implementation.createHTMLDocument('test'); + const doctype = document.implementation.createDocumentType('test', '', ''); + const oldChild = htmlDocument.appendChild(document.createComment('')); + chai.assert.throws(() => htmlDocument.replaceChild(doctype, oldChild), 'HierarchyRequestError'); + }); + + it('throws if inserting a doctype after the document element', () => { + const element = document.appendChild(document.createElement('test')); + const oldChild = document.appendChild(document.createComment('')); + const doctype = document.implementation.createDocumentType('html', '', ''); + chai.assert.throws(() => document.replaceChild(doctype, oldChild), 'HierarchyRequestError'); + }); + + it('allows insert a doctype', () => { + const oldChild = document.appendChild(document.createComment('')); + const doctype = document.implementation.createDocumentType('html', '', ''); + document.replaceChild(doctype, oldChild); + chai.assert.equal(document.doctype, doctype); + }); + + it('correctly handles replacing a node with itself', () => { + const parent = document.appendChild(document.createElement('parent')) as slimdom.Element; + const element = parent.appendChild(document.createElement('child')) as slimdom.Element; + parent.replaceChild(element, element); + chai.assert.equal(parent.firstElementChild, element); + chai.assert.equal(element.nextElementSibling, null); + chai.assert.equal(element.previousElementSibling, null); + }); + + it('correctly handles replacing a node with its next sibling', () => { + const parent = document.appendChild(document.createElement('parent')) as slimdom.Element; + const element1 = parent.appendChild(document.createElement('child')) as slimdom.Element; + const element2 = parent.appendChild(document.createElement('child')) as slimdom.Element; + parent.replaceChild(element2, element1); + chai.assert.equal(parent.firstElementChild, element2); + chai.assert.equal(element2.nextElementSibling, null); + chai.assert.equal(element2.previousElementSibling, null); + }); + }); + + describe('removeChild', () => { + it('throws if the child is not a child of the parent', () => { + const element = document.createElement('element'); + chai.assert.throws(() => document.removeChild(element), 'NotFoundError'); + }); + + describe('effect on ranges', () => { + let range: slimdom.Range; + beforeEach(() => { + range = document.createRange(); + }); + + it('updates ranges after the deletion point', () => { + const parent = document.createElement('parent'); + const child = parent.appendChild(document.createComment('test')); + parent.appendChild(document.createTextNode('test')); + range.setStartAfter(parent.lastChild!); + range.collapse(true); + chai.assert.equal(range.startContainer, parent); + chai.assert.equal(range.startOffset, 2); + parent.removeChild(child); + chai.assert.equal(range.startContainer, parent); + chai.assert.equal(range.startOffset, 1); + }); + }); + }); +}); diff --git a/test/mutations/MutationObserver.tests.ts b/test/mutations/MutationObserver.tests.ts deleted file mode 100644 index 3bee6b7..0000000 --- a/test/mutations/MutationObserver.tests.ts +++ /dev/null @@ -1,222 +0,0 @@ -import slimdom from '../../src/index'; - -import Document from '../../src/Document'; -import Element from '../../src/Element'; -import Text from '../../src/Text'; -import MutationObserver from '../../src/mutations/MutationObserver'; - -import * as chai from 'chai'; -import * as lolex from 'lolex'; - -describe('MutationObserver', () => { - let clock: lolex.Clock; - before(() => { - clock = lolex.install(); - }); - - after(() => { - clock.uninstall(); - }); - - let callbackCalled: boolean; - let callbackArgs: any[] = []; - function callback (...args: any[]) { - callbackCalled = true; - callbackArgs.push(args); - } - - let document: Document; - let element: Element; - let text: Text; - let observer: MutationObserver; - beforeEach(() => { - callbackCalled = false; - callbackArgs.length = 0; - - document = slimdom.createDocument(); - element = document.appendChild(document.createElement('root')) as Element; - text = element.appendChild(document.createTextNode('text')) as Text; - observer = new slimdom.MutationObserver(callback); - observer.observe(element, { - subtree: true, - characterData: true, - childList: true, - attributes: true, - userData: true - }); - }); - - afterEach(() => { - observer.disconnect(); - }); - - describe('synchronous usage', () => { - it('responds to text changes', () => { - text.data = 'meep'; - - const queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'characterData'); - chai.assert.equal(queue[0].oldValue, 'text'); - chai.assert.equal(queue[0].target, text); - - clock.tick(100); - chai.assert(!callbackCalled, 'callback was not called'); - }); - - it('responds to attribute changes', () => { - element.setAttribute('test', 'meep'); - - const queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'attributes'); - chai.assert.equal(queue[0].attributeName, 'test'); - chai.assert.equal(queue[0].oldValue, null); - chai.assert.equal(queue[0].target, element); - - clock.tick(100); - chai.assert(!callbackCalled, 'callback was not called'); - }); - - it('ignores same-value attribute changes', () => { - element.setAttribute('test', 'meep'); - let queue = observer.takeRecords(); - - element.setAttribute('test', 'meep'); - - queue = observer.takeRecords(); - chai.assert.deepEqual(queue, []); - - clock.tick(100); - chai.assert(!callbackCalled, 'callback was not called'); - }); - - it('records previous attribute values', () => { - element.setAttribute('test', 'meep'); - let queue = observer.takeRecords(); - - element.setAttribute('test', 'maap'); - - queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'attributes'); - chai.assert.equal(queue[0].attributeName, 'test'); - chai.assert.equal(queue[0].oldValue, 'meep'); - chai.assert.equal(queue[0].target, element); - - clock.tick(100); - chai.assert(!callbackCalled, 'callback was not called'); - }); - - it('responds to userData changes', () => { - const data = {}; - element.setUserData('test', data); - - const queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'userData'); - chai.assert.equal(queue[0].attributeName, 'test'); - chai.assert.equal(queue[0].oldValue, null); - chai.assert.equal(queue[0].target, element); - - clock.tick(100); - chai.assert(!callbackCalled, 'callback was not called'); - }); - - it('responds to insertions (appendChild)', () => { - const newElement = document.createElement('meep'); - element.appendChild(newElement); - - const queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'childList'); - chai.assert.deepEqual(queue[0].addedNodes, [ newElement ]); - chai.assert.deepEqual(queue[0].removedNodes, []); - chai.assert.equal(queue[0].previousSibling, text); - chai.assert.equal(queue[0].nextSibling, null); - }); - - it('responds to insertions (replaceChild)', () => { - const newElement = document.createElement('meep'); - element.replaceChild(newElement, text); - - const queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'childList'); - chai.assert.deepEqual(queue[0].addedNodes, [ newElement ]); - chai.assert.deepEqual(queue[0].removedNodes, [ text ]); - chai.assert.equal(queue[0].previousSibling, null); - chai.assert.equal(queue[0].nextSibling, null); - }); - - it('responds to moves (insertBefore)', () => { - const newElement = document.createElement('meep'); - element.appendChild(newElement); - observer.takeRecords(); - - element.insertBefore(newElement, text); - - const queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'childList'); - chai.assert.deepEqual(queue[0].addedNodes, []); - chai.assert.deepEqual(queue[0].removedNodes, [ newElement ]); - chai.assert.equal(queue[0].previousSibling, text); - chai.assert.equal(queue[0].nextSibling, null); - - chai.assert.equal(queue[1].type, 'childList'); - chai.assert.deepEqual(queue[1].addedNodes, [ newElement ]); - chai.assert.deepEqual(queue[1].removedNodes, []); - chai.assert.equal(queue[1].previousSibling, null); - chai.assert.equal(queue[1].nextSibling, text); - }); - - it('responds to moves (replaceChild)', () => { - const newElement = document.createElement('meep'); - element.appendChild(newElement); - observer.takeRecords(); - - element.replaceChild(newElement, text); - - const queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'childList'); - chai.assert.deepEqual(queue[0].addedNodes, []); - chai.assert.deepEqual(queue[0].removedNodes, [ newElement ]); - chai.assert.equal(queue[0].previousSibling, text); - chai.assert.equal(queue[0].nextSibling, null); - - chai.assert.equal(queue[1].type, 'childList'); - chai.assert.deepEqual(queue[1].addedNodes, [ newElement ]); - chai.assert.deepEqual(queue[1].removedNodes, [ text ]); - chai.assert.equal(queue[1].previousSibling, null); - chai.assert.equal(queue[1].nextSibling, null); - }); - - it('continues tracking under a removed node until javascript re-enters the event loop', () => { - const newElement = element.appendChild(document.createElement('meep')) as Element; - const newText = newElement.appendChild(document.createTextNode('test')) as Text; - element.appendChild(newElement); - observer.takeRecords(); - - element.removeChild(newElement); - observer.takeRecords(); - - newText.replaceData(0, text.length, 'meep'); - let queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'characterData'); - chai.assert.equal(queue[0].oldValue, 'test'); - chai.assert.equal(queue[0].target, newText); - - newElement.removeChild(newText); - queue = observer.takeRecords(); - chai.assert.equal(queue[0].type, 'childList'); - chai.assert.equal(queue[0].target, newElement); - chai.assert.equal(queue[0].removedNodes[0], newText); - }); - }); - - describe('asynchronous usage', () => { - it('responds to text changes', () => { - text.data = 'meep'; - - clock.tick(100); - chai.assert(callbackCalled, 'callback was called'); - chai.assert.equal(callbackArgs[0][0][0].type, 'characterData'); - chai.assert.equal(callbackArgs[0][0][0].oldValue, 'text'); - chai.assert.equal(callbackArgs[0][0][0].target, text); - }); - }); -}); diff --git a/test/tsconfig.json b/test/tsconfig.json deleted file mode 100644 index b94df99..0000000 --- a/test/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "outDir": "./bin/", - "sourceMap": true, - "strict": true, - "module": "commonjs", - "target": "es5" - }, - "include": [ - "./**/*" - ], - "exclude": [ - "bin" - ] -} diff --git a/test/web-platform-tests/SlimdomTreeAdapter.ts b/test/web-platform-tests/SlimdomTreeAdapter.ts new file mode 100644 index 0000000..e575738 --- /dev/null +++ b/test/web-platform-tests/SlimdomTreeAdapter.ts @@ -0,0 +1,193 @@ +import * as parse5 from 'parse5'; + +import * as slimdom from '../../src/index'; +import Attr from '../../src/Attr'; +import { createElement } from '../../src/Element'; +import { appendAttribute } from '../../src/util/attrMutations'; + +function undefinedAsNull(value: T | undefined): T | null { + if (value === undefined) { + return null; + } + + return value; +} + +function qualifiedName(namespace: string | undefined, prefix: string | undefined, name: string) { + return prefix ? `${prefix}:${name}` : name; +} + +export default class SlimdomTreeAdapter implements parse5.AST.TreeAdapter { + private _globalDocument = new slimdom.Document(); + private _mode: parse5.AST.DocumentMode = 'no-quirks'; + + createDocument(): parse5.AST.Document { + return this._globalDocument.implementation.createDocument(null, ''); + } + + createDocumentFragment(): parse5.AST.DocumentFragment { + throw new Error('Method not implemented.'); + } + + createElement(tagName: string, namespaceURI: string, attrs: parse5.AST.Default.Attribute[]): parse5.AST.Element { + const [localName, prefix] = tagName.indexOf(':') >= 0 ? tagName.split(':') : [tagName, null]; + // Create element without validation, as per HTML parser spec + const element = createElement(this._globalDocument, localName!, namespaceURI, prefix); + attrs.forEach(attr => { + // Create Attr node without validation, as per HTML parser spec + const attribute = new Attr( + undefinedAsNull(attr.namespace), + undefinedAsNull(attr.prefix), + attr.name, + attr.value, + element + ); + attribute.ownerDocument = this._globalDocument; + appendAttribute(attribute, element); + }); + return element; + } + + createCommentNode(data: string): parse5.AST.CommentNode { + return this._globalDocument.createComment(data); + } + + appendChild(parentNode: parse5.AST.ParentNode, newNode: parse5.AST.Node): void { + (parentNode as slimdom.Node).appendChild(newNode as slimdom.Node); + } + + insertBefore(parentNode: parse5.AST.ParentNode, newNode: parse5.AST.Node, referenceNode: parse5.AST.Node): void { + (parentNode as slimdom.Node).insertBefore(newNode as slimdom.Node, referenceNode as slimdom.Node); + } + + setTemplateContent(templateElement: parse5.AST.Element, contentElement: parse5.AST.DocumentFragment): void { + throw new Error('Method not implemented.'); + } + + getTemplateContent(templateElement: parse5.AST.Element): parse5.AST.DocumentFragment { + throw new Error('Method not implemented.'); + } + + setDocumentType(document: parse5.AST.Document, name: string, publicId: string, systemId: string): void { + const doctype = this._globalDocument.implementation.createDocumentType(name, publicId, systemId); + const doc = document as slimdom.Document; + if (doc.doctype) { + doc.replaceChild(doctype, doc.doctype); + } else { + doc.insertBefore(doctype, doc.documentElement); + } + } + + setDocumentMode(document: parse5.AST.Document, mode: parse5.AST.DocumentMode): void { + this._mode = mode; + } + + getDocumentMode(document: parse5.AST.Document): parse5.AST.DocumentMode { + return this._mode; + } + + detachNode(node: parse5.AST.Node): void { + const parent = (node as slimdom.Node).parentNode; + if (parent) { + parent.removeChild(node as slimdom.Node); + } + } + + insertText(parentNode: parse5.AST.ParentNode, text: string): void { + const lastChild = (parentNode as slimdom.Node).lastChild; + if (lastChild && lastChild.nodeType === slimdom.Node.TEXT_NODE) { + (lastChild as slimdom.Text).appendData(text); + return; + } + + (parentNode as slimdom.Node).appendChild(this._globalDocument.createTextNode(text)); + } + + insertTextBefore(parentNode: parse5.AST.ParentNode, text: string, referenceNode: parse5.AST.Node): void { + const sibling = referenceNode && (referenceNode as slimdom.Node).previousSibling; + if (sibling && sibling.nodeType === slimdom.Node.TEXT_NODE) { + (sibling as slimdom.Text).appendData(text); + return; + } + + (parentNode as slimdom.Node).insertBefore(this._globalDocument.createTextNode(text), referenceNode as slimdom.Node); + } + + adoptAttributes(recipient: parse5.AST.Element, attrs: parse5.AST.Default.Attribute[]): void { + const element = recipient as slimdom.Element; + attrs.forEach(attr => { + if (!element.hasAttributeNS(undefinedAsNull(attr.namespace), attr.name)) { + element.setAttributeNS( + undefinedAsNull(attr.namespace), + qualifiedName(attr.namespace, attr.prefix, attr.name), + attr.value + ); + } + }); + } + + getFirstChild(node: parse5.AST.ParentNode): parse5.AST.Node { + return (node as slimdom.Node).firstChild!; + } + + getChildNodes(node: parse5.AST.ParentNode): parse5.AST.Node[] { + return (node as slimdom.Node).childNodes; + } + + getParentNode(node: parse5.AST.Node): parse5.AST.ParentNode { + return (node as slimdom.Node).parentNode!; + } + + getAttrList(element: parse5.AST.Element): parse5.AST.Default.Attribute[] { + return (element as slimdom.Element).attributes.map(attr => ({ + name: attr.localName, + namespace: attr.namespaceURI || undefined, + prefix: attr.prefix || undefined, + value: attr.value + })); + } + + getTagName(element: parse5.AST.Element): string { + return (element as slimdom.Element).tagName; + } + + getNamespaceURI(element: parse5.AST.Element): string { + return (element as slimdom.Element).namespaceURI!; + } + + getTextNodeContent(textNode: parse5.AST.TextNode): string { + return (textNode as slimdom.Text).data; + } + + getCommentNodeContent(commentNode: parse5.AST.CommentNode): string { + return (commentNode as slimdom.Comment).data; + } + + getDocumentTypeNodeName(doctypeNode: parse5.AST.DocumentType): string { + return (doctypeNode as slimdom.DocumentType).name; + } + + getDocumentTypeNodePublicId(doctypeNode: parse5.AST.DocumentType): string { + return (doctypeNode as slimdom.DocumentType).publicId; + } + + getDocumentTypeNodeSystemId(doctypeNode: parse5.AST.DocumentType): string { + return (doctypeNode as slimdom.DocumentType).systemId; + } + + isTextNode(node: parse5.AST.Node): boolean { + return node && (node as slimdom.Node).nodeType === slimdom.Node.TEXT_NODE; + } + + isCommentNode(node: parse5.AST.Node): boolean { + return node && (node as slimdom.Node).nodeType === slimdom.Node.COMMENT_NODE; + } + + isDocumentTypeNode(node: parse5.AST.Node): boolean { + return node && (node as slimdom.Node).nodeType === slimdom.Node.DOCUMENT_TYPE_NODE; + } + + isElementNode(node: parse5.AST.Node): boolean { + return node && (node as slimdom.Node).nodeType === slimdom.Node.ELEMENT_NODE; + } +} diff --git a/test/web-platform-tests/webPlatform.tests.ts b/test/web-platform-tests/webPlatform.tests.ts new file mode 100644 index 0000000..07c9890 --- /dev/null +++ b/test/web-platform-tests/webPlatform.tests.ts @@ -0,0 +1,627 @@ +import * as chai from 'chai'; +import * as fs from 'fs'; +import * as parse5 from 'parse5'; +import * as path from 'path'; + +import * as slimdom from '../../src/index'; + +import SlimdomTreeAdapter from './SlimdomTreeAdapter'; + +const TEST_BLACKLIST: { [key: string]: (string | { [key: string]: string }) } = { + 'dom/historical.html': 'WebIDL parsing not implemented', + 'dom/interface-objects.html': 'window not implemented', + 'dom/interfaces.html': 'WebIDL parsing not implemented', + 'dom/collections': 'This implementation uses arrays instead of collection types', + 'dom/events': 'Events not implemented', + 'dom/lists': 'DOMTokenList (Element#classList) not implemented', + 'dom/nodes/append-on-Document.html': 'ParentNode#append not implemented', + 'dom/nodes/attributes.html': { + 'setAttribute should lowercase its name argument (upper case attribute)': + 'HTML attribute lowercasing not implemented', + 'setAttribute should lowercase its name argument (mixed case attribute)': + 'HTML attribute lowercasing not implemented', + 'Attributes should work in document fragments.': 'Element#attributes not implemented as NamedNodeMap', + 'Only lowercase attributes are returned on HTML elements (upper case attribute)': + 'HTML attribute lowercasing not implemented', + 'Only lowercase attributes are returned on HTML elements (mixed case attribute)': + 'HTML attribute lowercasing not implemented', + 'setAttributeNode, if it fires mutation events, should fire one with the new node when resetting an existing attribute (outer shell)': + 'Mutation events not implemented', + 'getAttributeNames tests': 'Element#getAttributeNames not implemented', + 'Own property correctness with basic attributes': 'Element#attributes not implemented as NamedNodeMap', + 'Own property correctness with non-namespaced attribute before same-name namespaced one': + 'Element#attributes not implemented as NamedNodeMap', + 'Own property correctness with namespaced attribute before same-name non-namespaced one': + 'Element#attributes not implemented as NamedNodeMap', + 'Own property correctness with two namespaced attributes with the same name-with-prefix': + 'Element#attributes not implemented as NamedNodeMap', + 'Own property names should only include all-lowercase qualified names for an HTML element in an HTML document': + 'Element#attributes not implemented as NamedNodeMap', + 'Own property names should include all qualified names for a non-HTML element in an HTML document': + 'Element#attributes not implemented as NamedNodeMap', + 'Own property names should include all qualified names for an HTML element in a non-HTML document': + 'Element#attributes not implemented as NamedNodeMap' + }, + 'dom/nodes/case.html': 'HTML case behavior not implemented', + 'dom/nodes/CharacterData-remove.html': 'ChildNode#remove not implemented', + 'dom/nodes/ChildNode-after.html': 'ChildNode#after not implemented', + 'dom/nodes/ChildNode-before.html': 'ChildNode#before not implemented', + 'dom/nodes/ChildNode-replaceWith.html': 'ChildNode#replaceWith not implemented', + 'dom/nodes/Comment-constructor.html': 'Comment constructor not implemented', + 'dom/nodes/Document-characterSet-normalization.html': 'Document#characterSet not implemented', + 'dom/nodes/Document-constructor.html': { + 'new Document(): URL parsing': 'HTMLAnchorElement not implemented' + }, + 'dom/nodes/Document-contentType': 'Document#contentType not implemented', + 'dom/nodes/Document-createAttribute.html': { + 'HTML document.createAttribute("TITLE")': 'HTML attribute lowercasing not implemented' + }, + 'dom/nodes/Document-createElement.html': 'Document load using iframe not implemented', + 'dom/nodes/Document-createElement-namespace.html': 'DOMParser / contentType not implemented', + 'dom/nodes/Document-createElement-namespace-tests': 'Document load using iframe not implemented', + 'dom/nodes/Document-createElementNS.html': 'Document load using iframe not implemented', + 'dom/nodes/Document-createEvent.html': 'Document#createEvent not implemented', + 'dom/nodes/Document-createTreeWalker.html': 'Document#createTreeWalker not implemented', + 'dom/nodes/Document-getElementById.html': 'Document#getElementById not implemented', + 'dom/nodes/Document-getElementsByTagName.html': 'Document#getElementsByTagName not implemented', + 'dom/nodes/Document-getElementsByTagNameNS.html': 'Document#getElementsByTagNameNS not implemented', + 'dom/nodes/Document-URL.sub.html': 'Document#URL not implemented', + 'dom/nodes/DocumentType-literal.html': 'Depends on HTML parsing', + 'dom/nodes/DocumentType-remove.html': 'ChildNode#remove not implemented', + 'dom/nodes/DOMImplementation-createDocument.html': { + 'createDocument test: metadata for "http://www.w3.org/1999/xhtml","",null': 'HTML contentType not implemented', + 'createDocument test: metadata for "http://www.w3.org/2000/svg","",null': 'SVG contentType not implemented' + }, + 'dom/nodes/DOMImplementation-createDocumentType.html': 'DocumentType#ownerDocument not implemented per spec', + 'dom/nodes/DOMImplementation-createHTMLDocument.html': 'HTML*Element interfaces not implemented', + 'dom/nodes/DOMImplementation-hasFeature.html': 'DOMImplementation#hasFeature not implemented', + 'dom/nodes/Element-children.html': 'Element#children not implemented as HTMLCollection', + 'dom/nodes/Element-classlist.html': 'Element#classList not implemented', + 'dom/nodes/Element-closest.html': 'Element#closest not implemented', + 'dom/nodes/Element-getElementsByClassName.html': 'Element#getElementsByClassName not implemented', + 'dom/nodes/Element-getElementsByTagName-change-document-HTMLNess.html': + 'Element#getElementsByTagName not implemented', + 'dom/nodes/Element-getElementsByTagName-change-document-HTMLNess-iframe.html': + 'Element#getElementsByTagName not implemented', + 'dom/nodes/Element-getElementsByTagName.html': 'Element#getElementsByTagName not implemented', + 'dom/nodes/Element-getElementsByTagNameNS.html': 'Element#getElementsByTagNameNS not implemented', + 'dom/nodes/Element-insertAdjacentElement.html': 'Element#insertAdjacentElement not implemented', + 'dom/nodes/Element-insertAdjacentText.html': 'Element#insertAdjacentText not implemented', + 'dom/nodes/Element-matches.html': 'Element#matches not implemented', + 'dom/nodes/Element-remove.html': 'ChildNode#remove not implemented', + 'dom/nodes/Element-tagName.html': 'HTML tagName uppercasing not implemented', + 'dom/nodes/Element-webkitMatchesSelector.html': 'Element#webkitMatchesSelector not implemented', + 'dom/nodes/insert-adjacent.html': 'Element#insertAdjacentElement / Element#insertAdjacentText not implemented', + 'dom/nodes/MutationObserver-attributes.html': { + 'attributes Element.id: update, no oldValue, mutation': 'Element#id not implemented', + 'attributes Element.id: update mutation': 'Element#id not implemented', + 'attributes Element.id: empty string update mutation': 'Element#id not implemented', + 'attributes Element.id: same value mutation': 'Element#id not implemented', + 'attributes Element.unknown: IDL attribute no mutation': 'Element#id not implemented', + 'attributes HTMLInputElement.type: type update mutation': 'HTMLInputElement not implemented', + 'attributes Element.className: new value mutation': 'Element#className not implemented', + 'attributes Element.className: empty string update mutation': 'Element#className not implemented', + 'attributes Element.className: same value mutation': 'Element#className not implemented', + 'attributes Element.className: same multiple values mutation': 'Element#className not implemented', + 'attributes Element.classList.add: single token addition mutation': 'Element#classList not implemented', + 'attributes Element.classList.add: multiple tokens addition mutation': 'Element#classList not implemented', + 'attributes Element.classList.add: syntax err/no mutation': 'Element#classList not implemented', + 'attributes Element.classList.add: invalid character/no mutation': 'Element#classList not implemented', + 'attributes Element.classList.add: same value mutation': 'Element#classList not implemented', + 'attributes Element.classList.remove: single token removal mutation': 'Element#classList not implemented', + 'attributes Element.classList.remove: multiple tokens removal mutation': 'Element#classList not implemented', + 'attributes Element.classList.remove: missing token removal mutation': 'Element#classList not implemented', + 'attributes Element.classList.toggle: token removal mutation': 'Element#classList not implemented', + 'attributes Element.classList.toggle: token addition mutation': 'Element#classList not implemented', + 'attributes Element.classList.toggle: forced token removal mutation': 'Element#classList not implemented', + 'attributes Element.classList.toggle: forced missing token removal no mutation': + 'Element#classList not implemented', + 'attributes Element.classList.toggle: forced existing token addition no mutation': + 'Element#classList not implemented', + 'attributes Element.classList.toggle: forced token addition mutation': 'Element#classList not implemented', + 'attributes Element.removeAttribute: removal no mutation': 'Element#id not implemented', + 'childList HTMLInputElement.removeAttribute: type removal mutation': 'Element#id not implemented', + 'attributes Element.removeAttributeNS: removal no mutation': 'Element#id not implemented', + 'attributes Element.removeAttributeNS: prefixed attribute removal no mutation': 'Element#id not implemented', + 'attributes/attributeFilter Element.id/Element.className: update mutation': 'attributeFilter not implemented', + 'attributes/attributeFilter Element.id/Element.className: multiple filter update mutation': + 'attributeFilter not implemented', + 'attributeOldValue alone Element.id: update mutation': 'Element#id not implemented', + 'attributeFilter alone Element.id/Element.className: multiple filter update mutation': + 'attributeFilter not implemented', + 'childList false: no childList mutation': 'Element#textContent setter not implemented' + }, + 'dom/nodes/MutationObserver-characterData.html': { + 'characterData Range.deleteContents: child and data removal mutation': 'Range#deleteContents not implemented', + 'characterData Range.deleteContents: child and data removal mutation (2)': 'Range#deleteContents not implemented', + 'characterData Range.extractContents: child and data removal mutation': 'Range#extractContents not implemented', + 'characterData Range.extractContents: child and data removal mutation (2)': 'Range#extractContents not implemented' + }, + 'dom/nodes/MutationObserver-childList.html': { + 'childList Node.textContent: replace content mutation': 'Element#textContent setter not implemented', + 'childList Node.textContent: no previous content mutation': 'Element#textContent setter not implemented', + 'childList Node.textContent: textContent no mutation': 'Element#textContent setter not implemented', + 'childList Node.textContent: empty string mutation': 'Element#textContent setter not implemented', + 'childList Range.deleteContents: child removal mutation': 'Range#deleteContents not implemented', + 'childList Range.deleteContents: child and data removal mutation': 'Range#deleteContents not implemented', + 'childList Range.extractContents: child removal mutation': 'Range#extractContents not implemented', + 'childList Range.extractContents: child and data removal mutation': 'Range#extractContents not implemented', + 'childList Range.insertNode: child insertion mutation': 'Range#insertNode not implemented', + 'childList Range.insertNode: children insertion mutation': 'Range#insertNode not implemented', + 'childList Range.surroundContents: children removal and addition mutation': 'Range#surroundContents not implemented' + }, + 'dom/nodes/MutationObserver-disconnect.html': 'Element#id not implemented', + 'dom/nodes/MutationObserver-document.html': 'Running script during parsing not implemented', + 'dom/nodes/MutationObserver-inner-outer.html': 'Element#innerHTML / Element#outerHTML not implemented', + 'dom/nodes/MutationObserver-subtree.html': 'Element#id not implemented', + 'dom/nodes/MutationObserver-takeRecords.html': 'Element#textContent setter not implemented', + 'dom/nodes/Node-baseURI.html': 'Node#baseURI not implemented', + 'dom/nodes/Node-childNodes.html': 'Node#childNodes not implemented as HTMLCollection', + 'dom/nodes/Node-cloneNode.html': { + 'createElement(a)': 'HTMLElement interfaces not implemented', + 'createElement(abbr)': 'HTMLElement interfaces not implemented', + 'createElement(acronym)': 'HTMLElement interfaces not implemented', + 'createElement(address)': 'HTMLElement interfaces not implemented', + 'createElement(applet)': 'HTMLElement interfaces not implemented', + 'createElement(area)': 'HTMLElement interfaces not implemented', + 'createElement(article)': 'HTMLElement interfaces not implemented', + 'createElement(aside)': 'HTMLElement interfaces not implemented', + 'createElement(audio)': 'HTMLElement interfaces not implemented', + 'createElement(b)': 'HTMLElement interfaces not implemented', + 'createElement(base)': 'HTMLElement interfaces not implemented', + 'createElement(bdi)': 'HTMLElement interfaces not implemented', + 'createElement(bdo)': 'HTMLElement interfaces not implemented', + 'createElement(bgsound)': 'HTMLElement interfaces not implemented', + 'createElement(big)': 'HTMLElement interfaces not implemented', + 'createElement(blockquote)': 'HTMLElement interfaces not implemented', + 'createElement(body)': 'HTMLElement interfaces not implemented', + 'createElement(br)': 'HTMLElement interfaces not implemented', + 'createElement(button)': 'HTMLElement interfaces not implemented', + 'createElement(canvas)': 'HTMLElement interfaces not implemented', + 'createElement(caption)': 'HTMLElement interfaces not implemented', + 'createElement(center)': 'HTMLElement interfaces not implemented', + 'createElement(cite)': 'HTMLElement interfaces not implemented', + 'createElement(code)': 'HTMLElement interfaces not implemented', + 'createElement(col)': 'HTMLElement interfaces not implemented', + 'createElement(colgroup)': 'HTMLElement interfaces not implemented', + 'createElement(data)': 'HTMLElement interfaces not implemented', + 'createElement(datalist)': 'HTMLElement interfaces not implemented', + 'createElement(dialog)': 'HTMLElement interfaces not implemented', + 'createElement(dd)': 'HTMLElement interfaces not implemented', + 'createElement(del)': 'HTMLElement interfaces not implemented', + 'createElement(details)': 'HTMLElement interfaces not implemented', + 'createElement(dfn)': 'HTMLElement interfaces not implemented', + 'createElement(dir)': 'HTMLElement interfaces not implemented', + 'createElement(div)': 'HTMLElement interfaces not implemented', + 'createElement(dl)': 'HTMLElement interfaces not implemented', + 'createElement(dt)': 'HTMLElement interfaces not implemented', + 'createElement(embed)': 'HTMLElement interfaces not implemented', + 'createElement(fieldset)': 'HTMLElement interfaces not implemented', + 'createElement(figcaption)': 'HTMLElement interfaces not implemented', + 'createElement(figure)': 'HTMLElement interfaces not implemented', + 'createElement(font)': 'HTMLElement interfaces not implemented', + 'createElement(footer)': 'HTMLElement interfaces not implemented', + 'createElement(form)': 'HTMLElement interfaces not implemented', + 'createElement(frame)': 'HTMLElement interfaces not implemented', + 'createElement(frameset)': 'HTMLElement interfaces not implemented', + 'createElement(h1)': 'HTMLElement interfaces not implemented', + 'createElement(h2)': 'HTMLElement interfaces not implemented', + 'createElement(h3)': 'HTMLElement interfaces not implemented', + 'createElement(h4)': 'HTMLElement interfaces not implemented', + 'createElement(h5)': 'HTMLElement interfaces not implemented', + 'createElement(h6)': 'HTMLElement interfaces not implemented', + 'createElement(head)': 'HTMLElement interfaces not implemented', + 'createElement(header)': 'HTMLElement interfaces not implemented', + 'createElement(hgroup)': 'HTMLElement interfaces not implemented', + 'createElement(hr)': 'HTMLElement interfaces not implemented', + 'createElement(html)': 'HTMLElement interfaces not implemented', + 'createElement(i)': 'HTMLElement interfaces not implemented', + 'createElement(iframe)': 'HTMLElement interfaces not implemented', + 'createElement(img)': 'HTMLElement interfaces not implemented', + 'createElement(input)': 'HTMLElement interfaces not implemented', + 'createElement(ins)': 'HTMLElement interfaces not implemented', + 'createElement(isindex)': 'HTMLElement interfaces not implemented', + 'createElement(kbd)': 'HTMLElement interfaces not implemented', + 'createElement(label)': 'HTMLElement interfaces not implemented', + 'createElement(legend)': 'HTMLElement interfaces not implemented', + 'createElement(li)': 'HTMLElement interfaces not implemented', + 'createElement(link)': 'HTMLElement interfaces not implemented', + 'createElement(main)': 'HTMLElement interfaces not implemented', + 'createElement(map)': 'HTMLElement interfaces not implemented', + 'createElement(mark)': 'HTMLElement interfaces not implemented', + 'createElement(marquee)': 'HTMLElement interfaces not implemented', + 'createElement(meta)': 'HTMLElement interfaces not implemented', + 'createElement(meter)': 'HTMLElement interfaces not implemented', + 'createElement(nav)': 'HTMLElement interfaces not implemented', + 'createElement(nobr)': 'HTMLElement interfaces not implemented', + 'createElement(noframes)': 'HTMLElement interfaces not implemented', + 'createElement(noscript)': 'HTMLElement interfaces not implemented', + 'createElement(object)': 'HTMLElement interfaces not implemented', + 'createElement(ol)': 'HTMLElement interfaces not implemented', + 'createElement(optgroup)': 'HTMLElement interfaces not implemented', + 'createElement(option)': 'HTMLElement interfaces not implemented', + 'createElement(output)': 'HTMLElement interfaces not implemented', + 'createElement(p)': 'HTMLElement interfaces not implemented', + 'createElement(param)': 'HTMLElement interfaces not implemented', + 'createElement(pre)': 'HTMLElement interfaces not implemented', + 'createElement(progress)': 'HTMLElement interfaces not implemented', + 'createElement(q)': 'HTMLElement interfaces not implemented', + 'createElement(rp)': 'HTMLElement interfaces not implemented', + 'createElement(rt)': 'HTMLElement interfaces not implemented', + 'createElement(ruby)': 'HTMLElement interfaces not implemented', + 'createElement(s)': 'HTMLElement interfaces not implemented', + 'createElement(samp)': 'HTMLElement interfaces not implemented', + 'createElement(script)': 'HTMLElement interfaces not implemented', + 'createElement(section)': 'HTMLElement interfaces not implemented', + 'createElement(select)': 'HTMLElement interfaces not implemented', + 'createElement(small)': 'HTMLElement interfaces not implemented', + 'createElement(source)': 'HTMLElement interfaces not implemented', + 'createElement(spacer)': 'HTMLElement interfaces not implemented', + 'createElement(span)': 'HTMLElement interfaces not implemented', + 'createElement(strike)': 'HTMLElement interfaces not implemented', + 'createElement(style)': 'HTMLElement interfaces not implemented', + 'createElement(sub)': 'HTMLElement interfaces not implemented', + 'createElement(summary)': 'HTMLElement interfaces not implemented', + 'createElement(sup)': 'HTMLElement interfaces not implemented', + 'createElement(table)': 'HTMLElement interfaces not implemented', + 'createElement(tbody)': 'HTMLElement interfaces not implemented', + 'createElement(td)': 'HTMLElement interfaces not implemented', + 'createElement(template)': 'HTMLElement interfaces not implemented', + 'createElement(textarea)': 'HTMLElement interfaces not implemented', + 'createElement(th)': 'HTMLElement interfaces not implemented', + 'createElement(time)': 'HTMLElement interfaces not implemented', + 'createElement(title)': 'HTMLElement interfaces not implemented', + 'createElement(tr)': 'HTMLElement interfaces not implemented', + 'createElement(tt)': 'HTMLElement interfaces not implemented', + 'createElement(track)': 'HTMLElement interfaces not implemented', + 'createElement(u)': 'HTMLElement interfaces not implemented', + 'createElement(ul)': 'HTMLElement interfaces not implemented', + 'createElement(var)': 'HTMLElement interfaces not implemented', + 'createElement(video)': 'HTMLElement interfaces not implemented', + 'createElement(unknown)': 'HTMLElement interfaces not implemented', + 'createElement(wbr)': 'HTMLElement interfaces not implemented', + 'createElementNS HTML': 'HTMLElement interfaces not implemented', + 'node with children': 'HTMLElement interfaces not implemented' + }, + 'dom/nodes/Node-compareDocumentPosition.html': 'Node#compareDocumentPosition not implemented', + 'dom/nodes/Node-constants.html': { + 'Constants for createDocumentPosition on Node interface object.': 'Node#compareDocumentPosition not implemented', + 'Constants for createDocumentPosition on Node prototype object.': 'Node#compareDocumentPosition not implemented', + 'Constants for createDocumentPosition on Element object.': 'Node#compareDocumentPosition not implemented', + 'Constants for createDocumentPosition on Text object.': 'Node#compareDocumentPosition not implemented' + }, + 'dom/nodes/Node-contains.html': 'Element#textContent setter not implemented', + 'dom/nodes/Node-isConnected.html': 'Node#isConnected not implemented', + 'dom/nodes/Node-isEqualNode.html': 'Node#isEqualNode not implemented', + 'dom/nodes/Node-isEqualNode-iframe1.html': 'Node#isEqualNode not implemented', + 'dom/nodes/Node-isEqualNode-iframe2.html': 'Node#isEqualNode not implemented', + 'dom/nodes/Node-isSameNode.html': 'Node#isSameNode not implemented', + 'dom/nodes/NodeList-Iterable.html': 'NodeList not implemented', + 'dom/nodes/Node-nodeName.html': { + 'For Element nodes, nodeName should return the same as tagName.': 'HTML tagName uppercasing not implemented' + }, + 'dom/nodes/Node-normalize.html': { + 'Node.normalize()': 'Element#textContent not implemented' + }, + 'dom/nodes/Node-parentNode.html': { + 'Removed iframe': 'Document load using iframe not implemented' + }, + 'dom/nodes/Node-properties.html': 'Element#textContent not implemented', + 'dom/nodes/Node-replaceChild.html': { + 'replaceChild should work in the presence of mutation events.': 'Mutation events not implemented' + }, + 'dom/nodes/Node-textContent.html': 'Node#textContent not implemented', + 'dom/nodes/ParentNode-append.html': 'ParentNode#append not implemented', + 'dom/nodes/ParentNode-prepend.html': 'ParentNode#prepend not implemented', + 'dom/nodes/ParentNode-querySelector-All-content.html': 'ParentNode#querySelectorAll not implemented', + 'dom/nodes/ParentNode-querySelector-All.html': 'ParentNode#querySelectorAll not implemented', + 'dom/nodes/prepend-on-Document.html': 'ParentNode#prepend not implemented', + 'dom/nodes/remove-unscopable.html': 'Methods not implemented', + 'dom/nodes/rootNode.html': 'Node#getRootNode not implemented', + 'dom/nodes/Text-constructor.html': 'Text constructor not implemented', + 'dom/ranges/Range-cloneContents.html': 'Range#cloneContents not implemented', + 'dom/ranges/Range-cloneRange.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-collapse.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-commonAncestorContainer.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-compareBoundaryPoints.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-comparePoint.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-constructor.html': 'Range constructor not implemented', + 'dom/ranges/Range-deleteContents.html': 'Range#deleteContents not implemented', + 'dom/ranges/Range-extractContents.html': 'Range#extractContents not implemented', + 'dom/ranges/Range-insertNode.html': 'Range#insertNode not implemented', + 'dom/ranges/Range-intersectsNode.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-isPointInRange.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-appendChild.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-appendData.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-dataChange.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-deleteData.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-insertBefore.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-insertData.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-removeChild.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-replaceChild.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-replaceData.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-mutations-splitText.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-selectNode.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-set.html': 'Element#textContent setter not implemented', + 'dom/ranges/Range-stringifier.html': 'Range#toString not implemented', + 'dom/ranges/Range-surroundContents.html': 'Range#surroundContents not implemented', + 'dom/traversal': 'NodeIterator and TreeWalker not implemented' +}; + +function getNodes(root: slimdom.Node, ...path: string[]): slimdom.Node[] { + if (!path.length) { + return [root]; + } + + const [nodeName, ...remainder] = path; + const matchingChildren = Array.from((root as slimdom.Element).childNodes).filter(n => n.nodeName === nodeName); + return matchingChildren.reduce((nodes, child) => nodes.concat(getNodes(child, ...remainder)), [] as slimdom.Node[]); +} + +function getAllText(root: slimdom.Node, ...path: string[]): string { + return getNodes(root, ...path).map(n => (n as slimdom.Text).data).join(''); +} + +function getAllScripts(doc: slimdom.Document, casePath: string) { + const scriptElements = (doc as any).getElementsByTagName('script'); + return scriptElements + .reduce((scripts: string[], el: slimdom.Element) => { + const src = el.attributes.find(a => a.name === 'src'); + if (src) { + let resolvedPath: string; + if (src.value === '/resources/WebIDLParser.js') { + // Historical alias, unfortunately not an actual file + // https://github.com/w3c/web-platform-tests/issues/5608 + resolvedPath = path.resolve(process.env.WEB_PLATFORM_TESTS_PATH, 'resources/webidl2/lib/webidl2.js'); + } else { + resolvedPath = src.value.startsWith('/') + ? path.resolve(process.env.WEB_PLATFORM_TESTS_PATH, src.value.substring(1)) + : path.resolve(path.dirname(casePath), src.value); + } + return scripts.concat([fs.readFileSync(resolvedPath, 'utf-8')]); + } + + return scripts.concat([getAllText(el, '#text')]); + }, []) + .join('\n'); +} + +function createTest(casePath: string, blacklistReason: { [key: string]: string } = {}): void { + const document = parse5.parse(fs.readFileSync(casePath, 'utf-8'), { + treeAdapter: new SlimdomTreeAdapter() + }) as slimdom.Document; + const title = getAllText(document, 'html', 'head', 'title', '#text') || path.basename(casePath); + const script = getAllScripts(document, casePath); + const scriptAsFunction = new Function('stubEnvironment', `with (stubEnvironment) { ${script} }`); + let stubs: { global: any; onLoadCallbacks: Function[]; onErrorCallback?: Function }; + + const { document: _, ...domInterfaces } = slimdom; + + function createStubEnvironment( + document: slimdom.Document + ): { global: any; onLoadCallbacks: Function[]; onErrorCallback?: Function } { + const onLoadCallbacks: Function[] = []; + let onErrorCallback: Function | undefined = undefined; + let global: any = { + document, + location: { href: casePath }, + window: null, + + get frames() { + return (document as any).getElementsByTagName('iframe').map((iframe: any) => { + if (!iframe.contentWindow) { + const stubs = createStubEnvironment(document.implementation.createHTMLDocument()); + iframe.contentWindow = stubs.global.window; + iframe.contentDocument = stubs.global.document; + iframe.document = stubs.global.document; + } + + return iframe; + }); + }, + + addEventListener(event: string, cb: Function) { + switch (event) { + case 'load': + onLoadCallbacks.push(cb); + break; + + case 'error': + onErrorCallback = cb; + break; + + default: + } + }, + + ...domInterfaces + }; + global.window = global; + global.parent = global; + global.self = global; + + return { global, onLoadCallbacks, onErrorCallback }; + } + + beforeEach(() => { + stubs = createStubEnvironment(document); + }); + + it(title, (done: Function) => { + try { + scriptAsFunction(stubs.global); + + if (!stubs.global.add_completion_callback) { + // No test harness found, assume file is not really a test case + done(); + return; + } + + stubs.global.add_completion_callback(function(tests: any[], testStatus: any) { + // TODO: Seems to be triggered by duplicate names in the createDocument tests + //chai.assert.equal(testStatus.status, testStatus.OK, testStatus.message); + tests.forEach(test => { + // Ignore results of blacklisted tests + if (!blacklistReason[test.name]) { + chai.assert.equal(test.status, testStatus.OK, `${test.name}: ${test.message}`); + } + }); + done(); + }); + + stubs.onLoadCallbacks.forEach(cb => cb({})); + + // "Run" iframes + (stubs.global.frames as any[]).forEach(iframe => { + if (iframe.onload) { + iframe.onload(); + } + }); + } catch (e) { + if (e instanceof chai.AssertionError) { + throw e; + } + + if (stubs.onErrorCallback) { + stubs.onErrorCallback(e); + } else { + throw e; + } + } + }); +} + +function createTests(dirPath: string): void { + fs.readdirSync(dirPath).forEach(entry => { + const entryPath = path.join(dirPath, entry); + const relativePath = path.relative(process.env.WEB_PLATFORM_TESTS_PATH, entryPath); + const blacklistReason = TEST_BLACKLIST[relativePath.replace(/\\/g, '/')]; + if (typeof blacklistReason === 'string') { + // Create a pending test + it(`${entry}: ${blacklistReason}`); + return; + } + + if (fs.statSync(entryPath).isDirectory()) { + describe(entry, () => { + createTests(entryPath); + }); + return; + } + + if (entry.endsWith('.html')) { + createTest(entryPath, blacklistReason); + } + }); +} + +describe('web platform DOM test suite', () => { + if (!process.env.WEB_PLATFORM_TESTS_PATH) { + it('requires the WEB_PLATFORM_TESTS_PATH environment variable to be set'); + return; + } + + (slimdom.Document.prototype as any).getElementsByTagName = function( + this: slimdom.Document, + tagName: string + ): slimdom.Node[] { + return (function getElementsByTagName(node: slimdom.Node): slimdom.Node[] { + return node.childNodes.reduce((elements, child) => { + if (child.nodeName === tagName) { + elements.push(child); + } + + if (child.nodeType === slimdom.Node.ELEMENT_NODE) { + elements = elements.concat(getElementsByTagName(child)); + } + + return elements; + }, [] as slimdom.Node[]); + })(this); + }; + + (slimdom.Document.prototype as any).getElementById = function getElementById( + this: slimdom.Node, + id: string + ): slimdom.Node | null { + return (function getElementById(node: slimdom.Node): slimdom.Node | null { + for (let child = node.firstChild; child; child = child.nextSibling) { + if (child.nodeType === slimdom.Node.ELEMENT_NODE && (child as slimdom.Element).getAttribute('id') === id) { + return child; + } + const descendant = getElementById(child); + if (descendant) { + return descendant; + } + } + + return null; + })(this); + }; + + // Stub not implemented properties to prevent createDocument tests from failing on these + Object.defineProperties(slimdom.Document.prototype, { + URL: { + value: 'about:blank' + }, + documentURI: { + value: 'about:blank' + }, + compatMode: { + value: 'CSS1Compat' + }, + characterSet: { + value: 'UTF-8' + }, + charset: { + value: 'UTF-8' + }, + inputEncoding: { + value: 'UTF-8' + }, + contentType: { + value: 'application/xml' + }, + origin: { + value: 'null' + }, + body: { + get() { + return this.getElementsByTagName('body')[0] || null; + } + }, + title: { + get() { + return getAllText(this, 'html', 'head', 'title', '#text'); + } + } + }); + + (slimdom.Document.prototype as any).querySelectorAll = () => []; + (slimdom.Document.prototype as any).querySelector = () => null; + + Object.defineProperties(slimdom.Attr.prototype, { + specified: { + value: true + }, + textContent: { + get() { + return this.nodeValue; + } + } + }); + Object.defineProperties(slimdom.CharacterData.prototype, { + textContent: { + get() { + return this.nodeValue; + } + } + }); + Object.defineProperties(slimdom.Element.prototype, { + style: { + value: {} + } + }); + + createTests(path.join(process.env.WEB_PLATFORM_TESTS_PATH, 'dom')); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..9962bfd --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "outDir": "lib", + "sourceMap": true, + "strict": true, + "module": "es6", + "target": "es5", + "declaration": true, + "lib": [ + "es2015" + ], + "types": [] + }, + "include": [ + "src/**/*" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 6895eac..86b6a47 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,11 +3,20 @@ "outDir": "lib", "sourceMap": true, "strict": true, - "module": "es6", - "target": "es5", - "declaration": true + "module": "commonjs", + "target": "es6", + "lib": [ + "es2015" + ], + "types": [ + "chai", + "mocha", + "node" + ], + "moduleResolution": "node" }, "include": [ - "src/**/*" + "src/**/*", + "test/**/*" ] }