diff --git a/examples/typescript-node-es6/src/index.ts b/examples/typescript-node-es6/src/index.ts index bb3792485..21e1e65bd 100644 --- a/examples/typescript-node-es6/src/index.ts +++ b/examples/typescript-node-es6/src/index.ts @@ -7,6 +7,8 @@ const source = ` const doc = new DOMParser().parseFromString(source, 'text/xml') +if (!doc) throw 'expected Document but was undefined' + const serialized = new XMLSerializer().serializeToString(doc) if (source !== serialized) { diff --git a/index.d.ts b/index.d.ts index 9e9f81b43..676269fb5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,43 +1,45 @@ /// -declare module "@xmldom/xmldom" { - var DOMParser: DOMParserStatic; - var XMLSerializer: XMLSerializerStatic; - var DOMImplementation: DOMImplementationStatic; - - interface DOMImplementationStatic { - new(): DOMImplementation; - } - - interface DOMParserStatic { - new (): DOMParser; - new (options: Options): DOMParser; - } - - interface XMLSerializerStatic { - new (): XMLSerializer; - } - - interface DOMParser { - parseFromString(xmlsource: string, mimeType?: string): Document; - } - - interface XMLSerializer { - serializeToString(node: Node): string; - } - - interface Options { - locator?: any; - errorHandler?: ErrorHandlerFunction | ErrorHandlerObject | undefined; - } - - interface ErrorHandlerFunction { - (level: string, msg: any): any; - } - - interface ErrorHandlerObject { - warning?: ((msg: any) => any) | undefined; - error?: ((msg: any) => any) | undefined; - fatalError?: ((msg: any) => any) | undefined; - } +declare module '@xmldom/xmldom' { + var DOMParser: DOMParserStatic + var XMLSerializer: XMLSerializerStatic + var DOMImplementation: DOMImplementationStatic + + interface DOMImplementationStatic { + new (): DOMImplementation + } + + interface DOMParserStatic { + new (): DOMParser + new (options: DOMParserOptions): DOMParser + } + + interface XMLSerializerStatic { + new (): XMLSerializer + } + + interface DOMParser { + parseFromString(source: string, mimeType?: string): Document | undefined + } + + interface XMLSerializer { + serializeToString(node: Node): string + } + + interface DOMParserOptions { + errorHandler?: ErrorHandlerFunction | ErrorHandlerObject + locator?: boolean + normalizeLineEndings?: (source: string) => string + xmlns?: Record + } + + interface ErrorHandlerFunction { + (level: 'warn' | 'error' | 'fatalError', msg: string): void + } + + interface ErrorHandlerObject { + warning?: (msg: string) => void + error?: (msg: string) => void + fatalError?: (msg: string) => void + } } diff --git a/lib/conventions.js b/lib/conventions.js index 0db33da67..5bdf031da 100644 --- a/lib/conventions.js +++ b/lib/conventions.js @@ -9,7 +9,7 @@ * * @template T * @param {T} object the object to freeze - * @param {Pick = Object} oc `Object` by default, + * @param {Pick} [oc=Object] `Object` by default, * allows to inject custom object constructor for tests * @returns {Readonly} * @@ -47,6 +47,155 @@ function assign(target, source) { return target } +/** + * A number of attributes are boolean attributes. + * The presence of a boolean attribute on an element represents the `true` value, + * and the absence of the attribute represents the `false` value. + * + * If the attribute is present, its value must either be the empty string + * or a value that is an ASCII case-insensitive match for the attribute's canonical name, + * with no leading or trailing whitespace. + * + * Note: The values `"true"` and `"false"` are not allowed on boolean attributes. + * To represent a `false` value, the attribute has to be omitted altogether. + * + * @see https://html.spec.whatwg.org/#boolean-attributes + * @see https://html.spec.whatwg.org/#attributes-3 + */ +var HTML_BOOLEAN_ATTRIBUTES = freeze({ + allowfullscreen: true, + async: true, + autofocus: true, + autoplay: true, + checked: true, + controls: true, + default: true, + defer: true, + disabled: true, + formnovalidate: true, + hidden: true, + ismap: true, + itemscope: true, + loop: true, + multiple: true, + muted: true, + nomodule: true, + novalidate: true, + open: true, + playsinline: true, + readonly: true, + required: true, + reversed: true, + selected: true, +}) + +/** + * Check if `name` is matching one of the HTML boolean attribute names. + * This method doesn't check if such attributes are allowed in the context of the current document/parsing. + * + * @param {string} name + * @return {boolean} + * @see HTML_BOOLEAN_ATTRIBUTES + * @see https://html.spec.whatwg.org/#boolean-attributes + * @see https://html.spec.whatwg.org/#attributes-3 + */ +function isHTMLBooleanAttribute(name) { + return HTML_BOOLEAN_ATTRIBUTES.hasOwnProperty(name.toLowerCase()) +} + +/** + * Void elements only have a start tag; end tags must not be specified for void elements. + * These elements should be written as self closing like this: ``. + * This should not be confused with optional tags that HTML allows to omit the end tag for + * (like `li`, `tr` and others), which can have content after them, + * so they can not be written as self closing. + * xmldom does not have any logic for optional end tags cases and will report them as a warning. + * Content that would go into the unopened element will instead be added as a sibling text node. + * + * @type {Readonly<{area: boolean, col: boolean, img: boolean, wbr: boolean, link: boolean, hr: boolean, source: boolean, br: boolean, input: boolean, param: boolean, meta: boolean, embed: boolean, track: boolean, base: boolean}>} + * @see https://html.spec.whatwg.org/#void-elements + * @see https://html.spec.whatwg.org/#optional-tags + */ +var HTML_VOID_ELEMENTS = freeze({ + area: true, + base: true, + br: true, + col: true, + embed: true, + hr: true, + img: true, + input: true, + link: true, + meta: true, + param: true, + source: true, + track: true, + wbr: true, +}) + +/** + * Check if `tagName` is matching one of the HTML void element names. + * This method doesn't check if such tags are allowed + * in the context of the current document/parsing. + * + * @param {string} tagName + * @return {boolean} + * @see HTML_VOID_ELEMENTS + * @see https://html.spec.whatwg.org/#void-elements + */ +function isHTMLVoidElement(tagName) { + return HTML_VOID_ELEMENTS.hasOwnProperty(tagName.toLowerCase()) +} + +/** + * Tag names that are raw text elements according to HTML spec. + * The value denotes whether they are escapable or not. + * + * @see isHTMLEscapableRawTextElement + * @see isHTMLRawTextElement + * @see https://html.spec.whatwg.org/#raw-text-elements + * @see https://html.spec.whatwg.org/#escapable-raw-text-elements + */ +var HTML_RAW_TEXT_ELEMENTS = freeze({ + script: false, + style: false, + textarea: true, + title: true, +}) + +/** + * Check if `tagName` is matching one of the HTML raw text element names. + * This method doesn't check if such tags are allowed + * in the context of the current document/parsing. + * + * @param {string} tagName + * @return {boolean} + * @see isHTMLEscapableRawTextElement + * @see HTML_RAW_TEXT_ELEMENTS + * @see https://html.spec.whatwg.org/#raw-text-elements + * @see https://html.spec.whatwg.org/#escapable-raw-text-elements + */ +function isHTMLRawTextElement(tagName) { + var key = tagName.toLowerCase(); + return HTML_RAW_TEXT_ELEMENTS.hasOwnProperty(key) && !HTML_RAW_TEXT_ELEMENTS[key]; +} +/** + * Check if `tagName` is matching one of the HTML escapable raw text element names. + * This method doesn't check if such tags are allowed + * in the context of the current document/parsing. + * + * @param {string} tagName + * @return {boolean} + * @see isHTMLRawTextElement + * @see HTML_RAW_TEXT_ELEMENTS + * @see https://html.spec.whatwg.org/#raw-text-elements + * @see https://html.spec.whatwg.org/#escapable-raw-text-elements + */ +function isHTMLEscapableRawTextElement(tagName) { + var key = tagName.toLowerCase(); + return HTML_RAW_TEXT_ELEMENTS.hasOwnProperty(key) && HTML_RAW_TEXT_ELEMENTS[key]; +} + /** * All mime types that are allowed as input to `DOMParser.parseFromString` * @@ -72,14 +221,32 @@ var MIME_TYPE = freeze({ * @param {string} [value] * @returns {boolean} * - * @see https://www.iana.org/assignments/media-types/text/html IANA MimeType registration - * @see https://en.wikipedia.org/wiki/HTML Wikipedia - * @see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString MDN - * @see https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#dom-domparser-parsefromstring */ + * @see [IANA MimeType registration](https://www.iana.org/assignments/media-types/text/html) + * @see [Wikipedia](https://en.wikipedia.org/wiki/HTML) + * @see [`DOMParser.parseFromString` @ MDN](https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString) + * @see [`DOMParser.parseFromString` @ HTML Specification](https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#dom-domparser-parsefromstring) + */ isHTML: function (value) { return value === MIME_TYPE.HTML }, + /** + * For both the `text/html` and the `application/xhtml+xml` namespace + * the spec defines that the HTML namespace is provided as the default in some cases. + * + * @param {string} mimeType + * @returns {boolean} + * + * @see https://dom.spec.whatwg.org/#dom-document-createelement + * @see https://dom.spec.whatwg.org/#dom-domimplementation-createdocument + * @see https://dom.spec.whatwg.org/#dom-domimplementation-createhtmldocument + */ + hasDefaultHTMLNamespace: function (mimeType) { + return ( + MIME_TYPE.isHTML(mimeType) || mimeType === MIME_TYPE.XML_XHTML_APPLICATION + ) + }, + /** * `application/xml`, the standard mime type for XML documents. * @@ -164,7 +331,14 @@ var NAMESPACE = freeze({ XMLNS: 'http://www.w3.org/2000/xmlns/', }) -exports.assign = assign; -exports.freeze = freeze; -exports.MIME_TYPE = MIME_TYPE; -exports.NAMESPACE = NAMESPACE; +exports.assign = assign +exports.freeze = freeze +exports.HTML_BOOLEAN_ATTRIBUTES = HTML_BOOLEAN_ATTRIBUTES +exports.HTML_RAW_TEXT_ELEMENTS = HTML_RAW_TEXT_ELEMENTS +exports.HTML_VOID_ELEMENTS = HTML_VOID_ELEMENTS +exports.isHTMLBooleanAttribute = isHTMLBooleanAttribute +exports.isHTMLRawTextElement = isHTMLRawTextElement +exports.isHTMLEscapableRawTextElement = isHTMLEscapableRawTextElement +exports.isHTMLVoidElement = isHTMLVoidElement +exports.MIME_TYPE = MIME_TYPE +exports.NAMESPACE = NAMESPACE diff --git a/lib/dom-parser.js b/lib/dom-parser.js index 94769a217..b54f81d04 100644 --- a/lib/dom-parser.js +++ b/lib/dom-parser.js @@ -1,3 +1,5 @@ +'use strict' + var conventions = require("./conventions"); var dom = require('./dom') var entities = require('./entities'); @@ -5,13 +7,14 @@ var sax = require('./sax'); var DOMImplementation = dom.DOMImplementation; +var MIME_TYPE = conventions.MIME_TYPE; var NAMESPACE = conventions.NAMESPACE; var ParseError = sax.ParseError; var XMLReader = sax.XMLReader; /** - * Normalizes line ending according to https://www.w3.org/TR/xml11/#sec-line-ends: + * Normalizes line ending according to : * * > XML parsed entities are often stored in computer files which, * > for editing convenience, are organized into lines. @@ -45,12 +48,26 @@ function normalizeLineEndings(input) { /** * @typedef DOMParserOptions - * @property {DOMHandler} [domBuilder] + * @property {typeof conventions.assign} [assign=Object.assign || conventions.assign] + * The method to use instead of `Object.assign` (or if not available `conventions.assign`), + * which is used to copy values from the options before they are used for parsing. + * @property {typeof DOMHandler} [domHandler] + * For internal testing: The class for creating an instance for handling events from the SAX parser. + * Warning: By configuring a faulty implementation, the specified behavior can completely be broken. * @property {Function} [errorHandler] - * @property {(string) => string} [normalizeLineEndings] used to replace line endings before parsing - * defaults to `normalizeLineEndings` - * @property {Locator} [locator] - * @property {Record} [xmlns] + * @property {boolean} [locator=true] + * Configures if the nodes created during parsing + * will have a `lineNumber` and a `columnNumber` attribute + * describing their location in the XML string. + * Default is true. + * @property {(string) => string} [normalizeLineEndings] + * used to replace line endings before parsing, defaults to `normalizeLineEndings` + * @property {object} [xmlns] + * The XML namespaces that should be assumed when parsing. + * The default namespace can be provided by the key that is the empty string. + * When the `mimeType` for HTML, XHTML or SVG are passed to `parseFromString`, + * the default namespace that will be used, + * will be overridden according to the specification. * * @see normalizeLineEndings */ @@ -60,7 +77,7 @@ function normalizeLineEndings(input) { * from a string into a DOM `Document`. * * _xmldom is different from the spec in that it allows an `options` parameter, - * to override the default behavior._ + * to control the behavior._ * * @param {DOMParserOptions} [options] * @constructor @@ -69,39 +86,125 @@ function normalizeLineEndings(input) { * @see https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#dom-parsing-and-serialization */ function DOMParser(options){ - this.options = options ||{locator:{}}; + + options = options || {locator:true}; + + /** + * The method to use instead of `Object.assign` (or if not available `conventions.assign`), + * which is used to copy values from the options before they are used for parsing. + * + * @type {function (target: object, source: object | null | undefined): object} + * @readonly + * @private + * @see conventions.assign + */ + this.assign = options.assign || Object.assign || conventions.assign + + /** + * For internal testing: The class for creating an instance for handling events from the SAX parser. + * __**Warning: By configuring a faulty implementation, the specified behavior can completely be broken.**__ + * + * @type {typeof DOMHandler} + * @readonly + * @private + */ + this.domHandler = options.domHandler || DOMHandler + + /** + * A function that can be invoked as the errorHandler instead of the default ones. + * @type {Function | undefined} + * @readonly + */ + this.errorHandler = options.errorHandler; + + /** + * used to replace line endings before parsing, defaults to `normalizeLineEndings` + * + * @type {(string) => string} + * @readonly + */ + this.normalizeLineEndings = options.normalizeLineEndings || normalizeLineEndings + + /** + * Configures if the nodes created during parsing + * will have a `lineNumber` and a `columnNumber` attribute + * describing their location in the XML string. + * Default is true. + * @type {boolean} + * @readonly + */ + this.locator = !!options.locator + + /** + * The default namespace can be provided by the key that is the empty string. + * When the `mimeType` for HTML, XHTML or SVG are passed to `parseFromString`, + * the default namespace that will be used, + * will be overridden according to the specification. + * @type {Readonly} + * @readonly + */ + this.xmlns = options.xmlns || {} } -DOMParser.prototype.parseFromString = function(source,mimeType){ - var options = this.options; - var sax = new XMLReader(); - var domBuilder = options.domBuilder || new DOMHandler();//contentHandler and LexicalHandler - var errorHandler = options.errorHandler; - var locator = options.locator; - var defaultNSMap = options.xmlns||{}; - var isHTML = /\/x?html?$/.test(mimeType);//mimeType.toLowerCase().indexOf('html') > -1; - var entityMap = isHTML ? entities.HTML_ENTITIES : entities.XML_ENTITIES; - if(locator){ - domBuilder.setDocumentLocator(locator) +/** + * Parses `source` using the options in the way configured by the `DOMParserOptions` of `this` `DOMParser`. + * If `mimeType` is `text/html` an HTML `Document` is created, otherwise an XML `Document` is created. + * + * __It behaves very different from the description in the living standard__: + * - Only allows the first argument to be a string (calls `error` handler otherwise.) + * - The second parameter is optional (defaults to `application/xml`) and can be any string, + * no `TypeError` will be thrown for values not listed in the spec. + * - Uses the `options` passed to the `DOMParser` constructor to modify the behavior/implementation. + * - Instead of creating a Document containing the error message, + * it triggers `errorHandler`(s) when unexpected input is found, which means it can return `undefined`. + * All error handlers can throw an `Error`, by default only the `fatalError` handler throws (a `ParserError`). + * - All errors thrown during the parsing that are not a `ParserError` are caught and reported using the `error` handler. + * - If no `ParserError` is thrown, this method returns the `DOMHandler.doc`, + * which most likely is the `Document` that has been created during parsing, or `undefined`. + * __**Warning: By configuring a faulty DOMHandler implementation, + * the specified behavior can completely be broken.**__ + * + * @param {string} source Only string input is possible! + * @param {string} [mimeType='application/xml'] + * the mimeType or contentType of the document to be created + * determines the `type` of document created (XML or HTML) + * @returns {Document | undefined} + * @throws ParseError for specific errors depending on the configured `errorHandler`s and/or `domBuilder` + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString + * @see https://html.spec.whatwg.org/#dom-domparser-parsefromstring-dev + */ +DOMParser.prototype.parseFromString = function (source, mimeType) { + var defaultNSMap = this.assign({}, this.xmlns) + var entityMap = entities.XML_ENTITIES + var defaultNamespace = defaultNSMap[''] || null + if (MIME_TYPE.hasDefaultHTMLNamespace(mimeType)) { + entityMap = entities.HTML_ENTITIES + defaultNamespace = NAMESPACE.HTML + } else if (mimeType === MIME_TYPE.XML_SVG_IMAGE) { + defaultNamespace = NAMESPACE.SVG } + defaultNSMap[''] = defaultNamespace + defaultNSMap.xml = defaultNSMap.xml || NAMESPACE.XML - sax.errorHandler = buildErrorHandler(errorHandler,domBuilder,locator); - sax.domBuilder = options.domBuilder || domBuilder; - if(isHTML){ - defaultNSMap[''] = NAMESPACE.HTML; + var domBuilder = new this.domHandler({ + mimeType: mimeType, + defaultNamespace: defaultNamespace, + }) + var locator = this.locator ? {} : undefined; + if (this.locator) { + domBuilder.setDocumentLocator(locator) } - defaultNSMap.xml = defaultNSMap.xml || NAMESPACE.XML; - var normalize = options.normalizeLineEndings || normalizeLineEndings; + + var sax = new XMLReader() + sax.errorHandler = buildErrorHandler(this.errorHandler, domBuilder, locator) + sax.domBuilder = domBuilder if (source && typeof source === 'string') { - sax.parse( - normalize(source), - defaultNSMap, - entityMap - ) + sax.parse(this.normalizeLineEndings(source), defaultNSMap, entityMap) } else { sax.errorHandler.error('invalid doc source') } - return domBuilder.doc; + return domBuilder.doc } function buildErrorHandler(errorImpl,domBuilder,locator){ if(!errorImpl){ @@ -128,33 +231,108 @@ function buildErrorHandler(errorImpl,domBuilder,locator){ return errorHandler; } -//console.log('#\n\n\n\n\n\n\n####') /** - * +ContentHandler+ErrorHandler - * +LexicalHandler+EntityResolver2 - * -DeclHandler-DTDHandler + * @typedef DOMHandlerOptions + * @property {string} [mimeType=MIME_TYPE.XML_APPLICATION] + * @property {string|null} [defaultNamespace=null] + */ +/** + * The class that is used to handle events from the SAX parser to create the related DOM elements. + * + * Some methods are only implemented as an empty function, + * since they are (at least currently) not relevant for xmldom. * - * DefaultHandler:EntityResolver, DTDHandler, ContentHandler, ErrorHandler - * DefaultHandler2:DefaultHandler,LexicalHandler, DeclHandler, EntityResolver2 - * @link http://www.saxproject.org/apidoc/org/xml/sax/helpers/DefaultHandler.html + * @constructor + * @param {DOMHandlerOptions} [options] + * @see http://www.saxproject.org/apidoc/org/xml/sax/ext/DefaultHandler2.html */ -function DOMHandler() { - this.cdata = false; +function DOMHandler(options) { + var opt = options || {} + /** + * The mime type is used to determine if the DOM handler will create an XML or HTML document. + * Only if it is set to `text/html` it will create an HTML document. + * It defaults to MIME_TYPE.XML_APPLICATION. + * + * @type {string} + * @readonly + * @see MIME_TYPE + */ + this.mimeType = opt.mimeType || MIME_TYPE.XML_APPLICATION + + /** + * The namespace to use to create an XML document. + * For the following reasons this is required: + * - The SAX API for `startDocument` doesn't offer any way to pass a namespace, + * since at that point there is no way for the parser to know what the default namespace from the document will be. + * - When creating using `DOMImplementation.createDocument` it is required to pass a namespace, + * to determine the correct `Document.contentType`, which should match `this.mimeType`. + * - When parsing an XML document with the `application/xhtml+xml` mimeType, + * the HTML namespace needs to be the default namespace. + * + * @type {string|null} + * @readonly + * @private + */ + this.defaultNamespace = opt.defaultNamespace || null + + /** + * @private + * @type {boolean} + */ + this.cdata = false + + + /** + * The last `Element` that was created by `startElement`. + * `endElement` sets it to the `currentElement.parentNode`. + * + * Note: The sax parser currently sets it to white space text nodes between tags. + * + * @type {Element | Node | undefined} + * @private + */ + this.currentElement = undefined + + /** + * The Document that is created as part of `startDocument`, + * and returned by `DOMParser.parseFromString`. + * + * @type {Document | undefined} + * @readonly + */ + this.doc = undefined + + /** + * The locator is stored as part of setDocumentLocator. + * It is controlled and mutated by the SAX parser + * to store the current parsing position. + * It is used by DOMHandler to set `columnNumber` and `lineNumber` + * on the DOM nodes. + * + * @type {Readonly | undefined} + * @readonly (the sax parser currently sometimes set's it) + * @private + */ + this.locator = undefined } function position(locator,node){ node.lineNumber = locator.lineNumber; node.columnNumber = locator.columnNumber; } -/** - * @see org.xml.sax.ContentHandler#startDocument - * @link http://www.saxproject.org/apidoc/org/xml/sax/ContentHandler.html - */ DOMHandler.prototype = { + /** + * Either creates an XML or an HTML document and stores it under `this.doc`. + * If it is an XML document, `this.defaultNamespace` is used to create it, + * and it will not contain any `childNodes`. + * If it is an HTML document, it will be created without any `childNodes`. + * + * @see http://www.saxproject.org/apidoc/org/xml/sax/ContentHandler.html + */ startDocument : function() { - this.doc = new DOMImplementation().createDocument(null, null, null); - if (this.locator) { - this.doc.documentURI = this.locator.systemId; - } + var impl = new DOMImplementation() + this.doc = MIME_TYPE.isHTML(this.mimeType) + ? impl.createHTMLDocument(false) + : impl.createDocument(this.defaultNamespace, '') }, startElement:function(namespaceURI, localName, qName, attrs) { var doc = this.doc; @@ -213,10 +391,17 @@ DOMHandler.prototype = { endDocument:function() { this.doc.normalize(); }, + /** + * Stores the locator to be able to set the `columnNumber` and `lineNumber` + * on the created DOM nodes. + * + * @param {Locator} locator + */ setDocumentLocator:function (locator) { - if(this.locator = locator){// && !('lineNumber' in locator)){ - locator.lineNumber = 0; - } + if (locator) { + locator.lineNumber = 0 + } + this.locator = locator }, //LexicalHandler comment:function(chars, start, length) { @@ -259,7 +444,7 @@ DOMHandler.prototype = { } function _locator(l){ if(l){ - return '\n@'+(l.systemId ||'')+'#[line:'+l.lineNumber+',col:'+l.columnNumber+']' + return '\n@#[line:'+l.lineNumber+',col:'+l.columnNumber+']' } } function _toString(chars,start,length){ diff --git a/lib/dom.js b/lib/dom.js index 633c34290..0bc123eaf 100644 --- a/lib/dom.js +++ b/lib/dom.js @@ -1,5 +1,7 @@ var conventions = require("./conventions"); - +var isHTMLRawTextElement = conventions.isHTMLRawTextElement; +var isHTMLVoidElement = conventions.isHTMLVoidElement; +var MIME_TYPE = conventions.MIME_TYPE; var NAMESPACE = conventions.NAMESPACE; /** @@ -156,23 +158,23 @@ NodeList.prototype = { * The number of nodes in the list. The range of valid child node indices is 0 to length-1 inclusive. * @standard level1 */ - length:0, + length:0, /** * Returns the indexth item in the collection. If index is greater than or equal to the number of nodes in the list, this returns null. * @standard level1 - * @param index unsigned long + * @param index unsigned long * Index into the collection. * @return Node - * The node at the indexth position in the NodeList, or null if that is not a valid index. + * The node at the indexth position in the NodeList, or null if that is not a valid index. */ item: function(index) { return this[index] || null; }, - toString:function(isHTML,nodeFilter){ + toString: function (nodeFilter) { for(var buf = [], i = 0;i`) in an HTML document. + * Instead, use `Element.getElementsByTagNameNS()`, + * which preserves the capitalization of the tag name. + * + * `Element.getElementsByTagName` is similar to `Document.getElementsByTagName()`, + * except that it only searches for elements that are descendants of the specified element. + * + * @param {string} qualifiedName + * @return {LiveNodeList} + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/getElementsByTagName + * @see https://dom.spec.whatwg.org/#concept-getelementsbytagname + */ + getElementsByTagName : function(qualifiedName){ + var isHTMLDocument = (this.nodeType === DOCUMENT_NODE ? this : this.ownerDocument).type === 'html' + var lowerQualifiedName = qualifiedName.toLowerCase() return new LiveNodeList(this,function(base){ var ls = []; - _visitNode(base,function(node){ - if(node !== base && node.nodeType == ELEMENT_NODE && (tagName === '*' || node.tagName == tagName)){ + _visitNode(base, function(node) { + if (node === base || node.nodeType !== ELEMENT_NODE) { + return + } + if (qualifiedName === '*') { ls.push(node); + } else { + var nodeQualifiedName = node.getQualifiedName(); + var matchingQName = isHTMLDocument && node.namespaceURI === NAMESPACE.HTML + ? lowerQualifiedName + : qualifiedName + if(nodeQualifiedName === matchingQName){ + ls.push(node); + } } }); return ls; @@ -1009,7 +1192,7 @@ Element.prototype = { } }); return ls; - + }); } }; @@ -1038,7 +1221,7 @@ CharacterData.prototype = { }, insertData: function(offset,text) { this.replaceData(offset,0,text); - + }, appendChild:function(newChild){ throw new Error(ExceptionMessage[HIERARCHY_REQUEST_ERR]) @@ -1123,29 +1306,26 @@ function ProcessingInstruction() { ProcessingInstruction.prototype.nodeType = PROCESSING_INSTRUCTION_NODE; _extends(ProcessingInstruction,Node); function XMLSerializer(){} -XMLSerializer.prototype.serializeToString = function(node,isHtml,nodeFilter){ - return nodeSerializeToString.call(node,isHtml,nodeFilter); +XMLSerializer.prototype.serializeToString = function (node, nodeFilter) { + return nodeSerializeToString.call(node, nodeFilter); } Node.prototype.toString = nodeSerializeToString; -function nodeSerializeToString(isHtml,nodeFilter){ +function nodeSerializeToString(nodeFilter) { var buf = []; - var refNode = this.nodeType == 9 && this.documentElement || this; + var refNode = this.nodeType === DOCUMENT_NODE && this.documentElement || this; var prefix = refNode.prefix; var uri = refNode.namespaceURI; - + if(uri && prefix == null){ - //console.log(prefix) var prefix = refNode.lookupPrefix(uri); if(prefix == null){ - //isHTML = true; var visibleNamespaces=[ {namespace:uri,prefix:null} //{namespace:uri,prefix:''} ] } } - serializeToString(this,buf,isHtml,nodeFilter,visibleNamespaces); - //console.log('###',this.nodeType,uri,prefix,buf.join('')) + serializeToString(this,buf,nodeFilter,visibleNamespaces); return buf.join(''); } @@ -1165,8 +1345,8 @@ function needNamespaceDefine(node, isHTML, visibleNamespaces) { if (prefix === "xml" && uri === NAMESPACE.XML || uri === NAMESPACE.XMLNS) { return false; } - - var i = visibleNamespaces.length + + var i = visibleNamespaces.length while (i--) { var ns = visibleNamespaces[i]; // get namespace prefix @@ -1193,10 +1373,12 @@ function addSerializedAttribute(buf, qualifiedName, value) { buf.push(' ', qualifiedName, '="', value.replace(/[<>&"\t\n\r]/g, _xmlEncoder), '"') } -function serializeToString(node,buf,isHTML,nodeFilter,visibleNamespaces){ +function serializeToString (node, buf, nodeFilter, visibleNamespaces) { if (!visibleNamespaces) { visibleNamespaces = []; } + var doc = node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument + var isHTML = doc.type === 'html' if(nodeFilter){ node = nodeFilter(node); @@ -1217,8 +1399,6 @@ function serializeToString(node,buf,isHTML,nodeFilter,visibleNamespaces){ var len = attrs.length; var child = node.firstChild; var nodeName = node.tagName; - - isHTML = NAMESPACE.isHTML(node.namespaceURI) || isHTML var prefixedNodeName = nodeName if (!isHTML && !node.prefix && node.namespaceURI) { @@ -1273,39 +1453,43 @@ function serializeToString(node,buf,isHTML,nodeFilter,visibleNamespaces){ addSerializedAttribute(buf, prefix ? 'xmlns:' + prefix : "xmlns", uri); visibleNamespaces.push({ prefix: prefix, namespace:uri }); } - serializeToString(attr,buf,isHTML,nodeFilter,visibleNamespaces); + serializeToString(attr,buf,nodeFilter,visibleNamespaces); } - // add namespace for current node + // add namespace for current node if (nodeName === prefixedNodeName && needNamespaceDefine(node, isHTML, visibleNamespaces)) { var prefix = node.prefix||''; var uri = node.namespaceURI; addSerializedAttribute(buf, prefix ? 'xmlns:' + prefix : "xmlns", uri); visibleNamespaces.push({ prefix: prefix, namespace:uri }); } - - if(child || isHTML && !/^(?:meta|link|img|br|hr|input)$/i.test(nodeName)){ - buf.push('>'); + // in XML elements can be closed when they have no children + var canCloseTag = !child; + if (canCloseTag && (isHTML || NAMESPACE.isHTML(node.namespaceURI))) { + // in HTML (doc or ns) only void elements can be closed right away + canCloseTag = isHTMLVoidElement(nodeName) + } + if (canCloseTag) { + buf.push("/>"); + } else { + buf.push(">"); //if is cdata child node - if(isHTML && /^script$/i.test(nodeName)){ + if (isHTML && isHTMLRawTextElement(nodeName)) { while(child){ if(child.data){ buf.push(child.data); }else{ - serializeToString(child, buf, isHTML, nodeFilter, visibleNamespaces.slice()); + serializeToString(child, buf, nodeFilter, visibleNamespaces.slice()); } child = child.nextSibling; } - }else - { + } else { while(child){ - serializeToString(child, buf, isHTML, nodeFilter, visibleNamespaces.slice()); + serializeToString(child, buf, nodeFilter, visibleNamespaces.slice()); child = child.nextSibling; } } - buf.push(''); - }else{ - buf.push('/>'); + buf.push(""); } // remove added visible namespaces //visibleNamespaces.length = startVisibleNamespaces; @@ -1314,7 +1498,7 @@ function serializeToString(node,buf,isHTML,nodeFilter,visibleNamespaces){ case DOCUMENT_FRAGMENT_NODE: var child = node.firstChild; while(child){ - serializeToString(child, buf, isHTML, nodeFilter, visibleNamespaces.slice()); + serializeToString(child, buf, nodeFilter, visibleNamespaces.slice()); child = child.nextSibling; } return; @@ -1496,7 +1680,7 @@ try{ } } }) - + function getTextContent(node){ switch(node.nodeType){ case ELEMENT_NODE: @@ -1523,12 +1707,11 @@ try{ }catch(e){//ie8 } -//if(typeof require == 'function'){ - exports.DocumentType = DocumentType; - exports.DOMException = DOMException; - exports.DOMImplementation = DOMImplementation; - exports.Element = Element; - exports.Node = Node; - exports.NodeList = NodeList; - exports.XMLSerializer = XMLSerializer; -//} +exports.Document = Document; +exports.DocumentType = DocumentType; +exports.DOMException = DOMException; +exports.DOMImplementation = DOMImplementation; +exports.Element = Element; +exports.Node = Node; +exports.NodeList = NodeList; +exports.XMLSerializer = XMLSerializer; diff --git a/lib/entities.js b/lib/entities.js index a942c5976..4260cfbbe 100644 --- a/lib/entities.js +++ b/lib/entities.js @@ -1,3 +1,5 @@ +'use strict' + var freeze = require('./conventions').freeze; /** diff --git a/lib/index.js b/lib/index.js index df827f6f8..85be0abc9 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,3 +1,5 @@ +'use strict' + var dom = require('./dom') exports.DOMImplementation = dom.DOMImplementation exports.XMLSerializer = dom.XMLSerializer diff --git a/lib/sax.js b/lib/sax.js index b706ef1af..48d8cfe46 100644 --- a/lib/sax.js +++ b/lib/sax.js @@ -1,4 +1,10 @@ -var NAMESPACE = require("./conventions").NAMESPACE; +'use strict' + +var conventions = require("./conventions"); +var isHTMLRawTextElement = conventions.isHTMLRawTextElement; +var isHTMLEscapableRawTextElement = conventions.isHTMLEscapableRawTextElement; +var NAMESPACE = conventions.NAMESPACE; +var MIME_TYPE = conventions.MIME_TYPE; //[4] NameStartChar ::= ":" | [A-Z] | "_" | [a-z] | [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x2FF] | [#x370-#x37D] | [#x37F-#x1FFF] | [#x200C-#x200D] | [#x2070-#x218F] | [#x2C00-#x2FEF] | [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] | [#x10000-#xEFFFF] //[4a] NameChar ::= NameStartChar | "-" | "." | [0-9] | #xB7 | [#x0300-#x036F] | [#x203F-#x2040] @@ -50,6 +56,7 @@ XMLReader.prototype = { } } function parse(source,defaultNSMapCopy,entityMap,domBuilder,errorHandler){ + var isHTML = MIME_TYPE.isHTML(domBuilder.mimeType); function fixedFromCharCode(code) { // String.prototype.fromCharCode does not supports // > 2 bytes unicode chars directly @@ -162,13 +169,21 @@ function parse(source,defaultNSMapCopy,entityMap,domBuilder,errorHandler){ var el = new ElementAttributes(); var currentNSMap = parseStack[parseStack.length-1].currentNSMap; //elStartEnd - var end = parseElementStartPart(source,tagStart,el,currentNSMap,entityReplacer,errorHandler); + var end = parseElementStartPart( + source, + tagStart, + el, + currentNSMap, + entityReplacer, + errorHandler, + isHTML + ) var len = el.length; if(!el.closed && fixSelfClosed(source,end,el.tagName,closeMap)){ el.closed = true; - if(!entityMap.nbsp){ + if(!isHTML){ errorHandler.warning('unclosed xml attribute'); } } @@ -191,7 +206,7 @@ function parse(source,defaultNSMapCopy,entityMap,domBuilder,errorHandler){ } } - if (NAMESPACE.isHTML(el.uri) && !el.closed) { + if (isHTML && !el.closed) { end = parseHtmlSpecialContent(source,end,el.tagName,entityReplacer,domBuilder) } else { end++; @@ -222,7 +237,9 @@ function copyLocator(f,t){ * @see #appendElement(source,elStartEnd,el,selfClosed,entityReplacer,domBuilder,parseStack); * @return end of the elementStartPart(end of elementEndPart for selfClosed el) */ -function parseElementStartPart(source,start,el,currentNSMap,entityReplacer,errorHandler){ +function parseElementStartPart( + source,start,el,currentNSMap,entityReplacer,errorHandler, isHTML +){ /** * @param {string} qname @@ -337,7 +354,7 @@ function parseElementStartPart(source,start,el,currentNSMap,entityReplacer,error errorHandler.warning('attribute "'+value+'" missed quot(")!'); addAttribute(attrName, value, start) }else{ - if(!NAMESPACE.isHTML(currentNSMap['']) || !value.match(/^(?:disabled|checked|selected)$/i)){ + if(!isHTML){ errorHandler.warning('attribute "'+value+'" missed value!! "'+value+'" instead!!') } addAttribute(value, value, start) @@ -385,7 +402,7 @@ function parseElementStartPart(source,start,el,currentNSMap,entityReplacer,error //case S_ATTR_NOQUOT_VALUE:void();break; case S_ATTR_SPACE: var tagName = el.tagName; - if (!NAMESPACE.isHTML(currentNSMap['']) || !attrName.match(/^(?:disabled|checked|selected)$/i)) { + if (!isHTML) { errorHandler.warning('attribute "'+attrName+'" missed value!! "'+attrName+'" instead2!!') } addAttribute(attrName, attrName, start); @@ -457,8 +474,6 @@ function appendElement(el,domBuilder,currentNSMap){ a.uri = NAMESPACE.XML; }if(prefix !== 'xmlns'){ a.uri = currentNSMap[prefix || ''] - - //{console.log('###'+a.qName,domBuilder.locator.systemId+'',currentNSMap,a.uri)} } } } @@ -490,24 +505,20 @@ function appendElement(el,domBuilder,currentNSMap){ } } function parseHtmlSpecialContent(source,elStartEnd,tagName,entityReplacer,domBuilder){ - if(/^(?:script|textarea)$/i.test(tagName)){ + // https://html.spec.whatwg.org/#raw-text-elements + // https://html.spec.whatwg.org/#escapable-raw-text-elements + // https://html.spec.whatwg.org/#cdata-rcdata-restrictions:raw-text-elements + // TODO: https://html.spec.whatwg.org/#cdata-rcdata-restrictions + var isEscapableRaw = isHTMLEscapableRawTextElement(tagName); + if(isEscapableRaw || isHTMLRawTextElement(tagName)){ var elEndStart = source.indexOf('',elStartEnd); var text = source.substring(elStartEnd+1,elEndStart); - if(/[&<]/.test(text)){ - if(/^script$/i.test(tagName)){ - //if(!/\]\]>/.test(text)){ - //lexHandler.startCDATA(); - domBuilder.characters(text,0,text.length); - //lexHandler.endCDATA(); - return elEndStart; - //} - }//}else{//text area + + if(isEscapableRaw){ text = text.replace(/&#?\w+;/g,entityReplacer); + } domBuilder.characters(text,0,text.length); return elEndStart; - //} - - } } return elStartEnd+1; } diff --git a/test/conventions/__snapshots__/html.test.js.snap b/test/conventions/__snapshots__/html.test.js.snap new file mode 100644 index 000000000..ff48bb910 --- /dev/null +++ b/test/conventions/__snapshots__/html.test.js.snap @@ -0,0 +1,295 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable allowfullscreen with value 'true' 1`] = ` +Array [ + "allowfullscreen", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable async with value 'true' 1`] = ` +Array [ + "async", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable autofocus with value 'true' 1`] = ` +Array [ + "autofocus", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable autoplay with value 'true' 1`] = ` +Array [ + "autoplay", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable checked with value 'true' 1`] = ` +Array [ + "checked", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable controls with value 'true' 1`] = ` +Array [ + "controls", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable default with value 'true' 1`] = ` +Array [ + "default", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable defer with value 'true' 1`] = ` +Array [ + "defer", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable disabled with value 'true' 1`] = ` +Array [ + "disabled", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable formnovalidate with value 'true' 1`] = ` +Array [ + "formnovalidate", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable hidden with value 'true' 1`] = ` +Array [ + "hidden", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable ismap with value 'true' 1`] = ` +Array [ + "ismap", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable itemscope with value 'true' 1`] = ` +Array [ + "itemscope", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable loop with value 'true' 1`] = ` +Array [ + "loop", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable multiple with value 'true' 1`] = ` +Array [ + "multiple", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable muted with value 'true' 1`] = ` +Array [ + "muted", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable nomodule with value 'true' 1`] = ` +Array [ + "nomodule", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable novalidate with value 'true' 1`] = ` +Array [ + "novalidate", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable open with value 'true' 1`] = ` +Array [ + "open", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable playsinline with value 'true' 1`] = ` +Array [ + "playsinline", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable readonly with value 'true' 1`] = ` +Array [ + "readonly", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable required with value 'true' 1`] = ` +Array [ + "required", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable reversed with value 'true' 1`] = ` +Array [ + "reversed", + true, +] +`; + +exports[`HTML_BOOLEAN_ATTRIBUTES should contain immutable selected with value 'true' 1`] = ` +Array [ + "selected", + true, +] +`; + +exports[`HTML_RAW_TEXT_ELEMENTS should contain immutable script with value 'true' 1`] = ` +Array [ + "script", + false, +] +`; + +exports[`HTML_RAW_TEXT_ELEMENTS should contain immutable style with value 'true' 1`] = ` +Array [ + "style", + false, +] +`; + +exports[`HTML_RAW_TEXT_ELEMENTS should contain immutable textarea with value 'true' 1`] = ` +Array [ + "textarea", + true, +] +`; + +exports[`HTML_RAW_TEXT_ELEMENTS should contain immutable title with value 'true' 1`] = ` +Array [ + "title", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable area with value 'true' 1`] = ` +Array [ + "area", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable base with value 'true' 1`] = ` +Array [ + "base", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable br with value 'true' 1`] = ` +Array [ + "br", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable col with value 'true' 1`] = ` +Array [ + "col", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable embed with value 'true' 1`] = ` +Array [ + "embed", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable hr with value 'true' 1`] = ` +Array [ + "hr", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable img with value 'true' 1`] = ` +Array [ + "img", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable input with value 'true' 1`] = ` +Array [ + "input", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable link with value 'true' 1`] = ` +Array [ + "link", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable meta with value 'true' 1`] = ` +Array [ + "meta", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable param with value 'true' 1`] = ` +Array [ + "param", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable source with value 'true' 1`] = ` +Array [ + "source", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable track with value 'true' 1`] = ` +Array [ + "track", + true, +] +`; + +exports[`HTML_VOID_ELEMENTS should contain immutable wbr with value 'true' 1`] = ` +Array [ + "wbr", + true, +] +`; diff --git a/test/conventions/__snapshots__/mime-type.test.js.snap b/test/conventions/__snapshots__/mime-type.test.js.snap index 6953345df..6e30da065 100644 --- a/test/conventions/__snapshots__/mime-type.test.js.snap +++ b/test/conventions/__snapshots__/mime-type.test.js.snap @@ -34,3 +34,10 @@ Array [ "application/xhtml+xml", ] `; + +exports[`MIME_TYPE should contain immutable hasDefaultHTMLNamespace with correct value 1`] = ` +Array [ + "hasDefaultHTMLNamespace", + [Function], +] +`; diff --git a/test/conventions/html.test.js b/test/conventions/html.test.js new file mode 100644 index 000000000..f9c25d793 --- /dev/null +++ b/test/conventions/html.test.js @@ -0,0 +1,134 @@ +'use strict' + +const { + HTML_BOOLEAN_ATTRIBUTES, + isHTMLBooleanAttribute, + HTML_RAW_TEXT_ELEMENTS, + isHTMLRawTextElement, + isHTMLEscapableRawTextElement, + HTML_VOID_ELEMENTS, + isHTMLVoidElement, +} = require('../../lib/conventions') + +describe('HTML_BOOLEAN_ATTRIBUTES', () => { + Object.keys(HTML_BOOLEAN_ATTRIBUTES).forEach((key) => { + const value = HTML_BOOLEAN_ATTRIBUTES[key] + it(`should contain immutable ${key} with value 'true'`, () => { + expect([key, value]).toMatchSnapshot() + try { + HTML_BOOLEAN_ATTRIBUTES[key] = 'boo' + } catch {} + expect(HTML_BOOLEAN_ATTRIBUTES[key]).toBe(value) + }) + }) +}) +describe('isHTMLBooleanAttribute', () => { + Object.keys(HTML_BOOLEAN_ATTRIBUTES).forEach((key) => { + it(`should detect attribute '${key}'`, () => { + expect(isHTMLBooleanAttribute(key)).toBe(true) + }) + const upperKey = key.toUpperCase() + it(`should detect attribute '${upperKey}'`, () => { + expect(isHTMLBooleanAttribute(upperKey)).toBe(true) + }) + const mixedKey = key[0].toUpperCase() + key.substring(1) + it(`should detect attribute '${mixedKey}'`, () => { + expect(isHTMLBooleanAttribute(mixedKey)).toBe(true) + }) + }) + it('should not detect prototype properties', () => { + expect(isHTMLBooleanAttribute('hasOwnProperty')).toBe(false) + expect(isHTMLBooleanAttribute('constructor')).toBe(false) + expect(isHTMLBooleanAttribute('prototype')).toBe(false) + expect(isHTMLBooleanAttribute('__proto__')).toBe(false) + }) +}) +describe('HTML_VOID_ELEMENTS', () => { + Object.keys(HTML_VOID_ELEMENTS).forEach((key) => { + const value = HTML_VOID_ELEMENTS[key] + it(`should contain immutable ${key} with value 'true'`, () => { + expect([key, value]).toMatchSnapshot() + try { + HTML_VOID_ELEMENTS[key] = 'boo' + } catch {} + expect(HTML_VOID_ELEMENTS[key]).toBe(true) + }) + }) +}) +describe('isHTMLVoidElement', () => { + Object.keys(HTML_VOID_ELEMENTS).forEach((key) => { + it(`should detect attribute '${key}'`, () => { + expect(isHTMLVoidElement(key)).toBe(true) + }) + const upperKey = key.toUpperCase() + it(`should detect attribute '${upperKey}'`, () => { + expect(isHTMLVoidElement(upperKey)).toBe(true) + }) + const mixedKey = key[0].toUpperCase() + key.substring(1) + it(`should detect attribute '${mixedKey}'`, () => { + expect(isHTMLVoidElement(mixedKey)).toBe(true) + }) + }) + it('should not detect prototype properties', () => { + expect(isHTMLVoidElement('hasOwnProperty')).toBe(false) + expect(isHTMLVoidElement('constructor')).toBe(false) + expect(isHTMLVoidElement('prototype')).toBe(false) + expect(isHTMLVoidElement('__proto__')).toBe(false) + }) +}) +describe('HTML_RAW_TEXT_ELEMENTS', () => { + Object.keys(HTML_RAW_TEXT_ELEMENTS).forEach((key) => { + const value = HTML_RAW_TEXT_ELEMENTS[key] + it(`should contain immutable ${key} with value 'true'`, () => { + expect([key, value]).toMatchSnapshot() + try { + HTML_RAW_TEXT_ELEMENTS[key] = 'boo' + } catch {} + expect(HTML_RAW_TEXT_ELEMENTS[key]).toBe(value) + }) + }) +}) +describe('isHTMLRawTextElement', () => { + Object.keys(HTML_RAW_TEXT_ELEMENTS).forEach((key) => { + const expected = HTML_RAW_TEXT_ELEMENTS[key] === false + it(`should detect attribute '${key}' as ${expected}`, () => { + expect(isHTMLRawTextElement(key)).toBe(expected) + }) + const upperKey = key.toUpperCase() + it(`should detect attribute '${upperKey}' as ${expected}`, () => { + expect(isHTMLRawTextElement(upperKey)).toBe(expected) + }) + const mixedKey = key[0].toUpperCase() + key.substring(1) + it(`should detect attribute '${mixedKey}' as ${expected}`, () => { + expect(isHTMLRawTextElement(mixedKey)).toBe(expected) + }) + }) + it('should not detect prototype properties', () => { + expect(isHTMLRawTextElement('hasOwnProperty')).toBe(false) + expect(isHTMLRawTextElement('constructor')).toBe(false) + expect(isHTMLRawTextElement('prototype')).toBe(false) + expect(isHTMLRawTextElement('__proto__')).toBe(false) + }) +}) +describe('isHTMLEscapableRawTextElement', () => { + Object.keys(HTML_RAW_TEXT_ELEMENTS).forEach((key) => { + const expected = HTML_RAW_TEXT_ELEMENTS[key] + it(`should detect attribute '${key}' as ${expected}`, () => { + expect(isHTMLEscapableRawTextElement(key)).toBe(expected) + }) + const upperKey = key.toUpperCase() + it(`should detect attribute '${upperKey}' as ${expected}`, () => { + expect(isHTMLEscapableRawTextElement(upperKey)).toBe(expected) + }) + const mixedKey = key[0].toUpperCase() + key.substring(1) + it(`should detect attribute '${mixedKey}' as ${expected}`, () => { + expect(isHTMLEscapableRawTextElement(mixedKey)).toBe(expected) + }) + }) + it('should not detect prototype properties', () => { + expect(isHTMLEscapableRawTextElement('hasOwnProperty')).toBe(false) + expect(isHTMLEscapableRawTextElement('constructor')).toBe(false) + expect(isHTMLEscapableRawTextElement('prototype')).toBe(false) + expect(isHTMLEscapableRawTextElement('__proto__')).toBe(false) + }) +}) diff --git a/test/dom-parser.test.js b/test/dom-parser.test.js index 009929444..5406c144b 100644 --- a/test/dom-parser.test.js +++ b/test/dom-parser.test.js @@ -1,6 +1,10 @@ 'use strict' const { DOMParser } = require('../lib') +const { assign, MIME_TYPE, NAMESPACE } = require('../lib/conventions') +const { __DOMHandler } = require('../lib/dom-parser') + +const NS_CUSTOM = 'custom-default-ns' describe('DOMParser', () => { describe('constructor', () => { @@ -8,34 +12,144 @@ describe('DOMParser', () => { const options = { locator: {} } const it = new DOMParser(options) - // TODO: is there a simpler way to test this that doesn't involve invoking parseFromString? - it.parseFromString('') + const doc = it.parseFromString('') + + const expected = { + columnNumber: 1, + lineNumber: 1, + } + expect(doc.documentElement).toMatchObject(expected) + }) + test('should use locator when options is not passed', () => { + const it = new DOMParser() + + const doc = it.parseFromString('') const expected = { columnNumber: 1, lineNumber: 1, } - expect(options.locator).toStrictEqual(expected) + expect(doc.documentElement).toMatchObject(expected) + }) + test("should not use locator when it's not set in options", () => { + const options = {} + const it = new DOMParser(options) + + const doc = it.parseFromString('') + + expect(doc.documentElement).not.toHaveProperty('columnNumber') + expect(doc.documentElement).not.toHaveProperty('lineNumber') + }) + + test('should set the default namespace to null by default', () => { + const options = { xmlns: {} } + const it = new DOMParser(options) + + const doc = it.parseFromString('') + + expect(doc.documentElement.namespaceURI).toBeNull() + }) + + test('should set the default namespace to null by default', () => { + const options = { xmlns: {} } + const it = new DOMParser(options) + + const doc = it.parseFromString('') + + expect(doc.documentElement.namespaceURI).toBeNull() }) test('should store passed options.xmlns for default mime type', () => { - const options = { xmlns: { '': 'custom-default-ns' } } + const xmlns = { '': NS_CUSTOM } + const options = { xmlns } const it = new DOMParser(options) - // TODO: is there a simpler way to test this that doesn't involve invoking parseFromString? const actual = it.parseFromString('') expect(actual.toString()).toBe('') + expect(actual.documentElement.namespaceURI).toBe(NS_CUSTOM) }) test('should store and modify passed options.xmlns for html mime type', () => { - const options = { xmlns: { '': 'custom-default-ns' } } - const it = new DOMParser(options) + const xmlns = { '': NS_CUSTOM } + const it = new DOMParser({ xmlns }) - // TODO: is there a simpler way to test this that doesn't involve invoking parseFromString? - it.parseFromString('', 'text/html') + const doc = it.parseFromString('', MIME_TYPE.HTML) - expect(options.xmlns['']).toBe('http://www.w3.org/1999/xhtml') + expect(doc.documentElement.namespaceURI).toBe(NAMESPACE.HTML) + expect(xmlns['']).toBe(NS_CUSTOM) + }) + + test('should store the default namespace for html mime type', () => { + const xmlns = {} + const it = new DOMParser({ xmlns }) + + const doc = it.parseFromString('', MIME_TYPE.HTML) + + expect(doc.documentElement.namespaceURI).toBe(NAMESPACE.HTML) + expect(xmlns).not.toHaveProperty('') + }) + + test('should store default namespace for XHTML mime type', () => { + const xmlns = {} + const it = new DOMParser({ xmlns }) + + const doc = it.parseFromString('', MIME_TYPE.XML_XHTML_APPLICATION) + + expect(doc.documentElement.namespaceURI).toBe(NAMESPACE.HTML) + expect(xmlns).not.toHaveProperty('') + }) + + test('should override default namespace for XHTML mime type', () => { + const xmlns = { '': NS_CUSTOM } + const it = new DOMParser({ xmlns }) + + const doc = it.parseFromString('', MIME_TYPE.XML_XHTML_APPLICATION) + + expect(doc.documentElement.namespaceURI).toBe(NAMESPACE.HTML) + expect(xmlns['']).toBe(NS_CUSTOM) + }) + describe('property assign', () => { + const OBJECT_ASSIGN = Object.assign + beforeAll(() => { + expect(OBJECT_ASSIGN).toBeDefined() + expect(typeof OBJECT_ASSIGN).toBe('function') + }) + afterEach(() => { + Object.assign = OBJECT_ASSIGN + }) + afterAll(() => { + expect(Object.assign).toBeDefined() + expect(typeof Object.assign).toBe('function') + }) + test('should use `options.assign` when passed', () => { + const stub = (t, s) => t + const it = new DOMParser({ assign: stub }) + + expect(it.assign).toBe(stub) + }) + + test('should use `Object.assign` when `options.assign` is undefined', () => { + expect(Object.assign).toBeDefined() + const it = new DOMParser({ assign: undefined }) + + expect(it.assign).toBe(Object.assign) + }) + + test('should use `Object.assign` when `options` is undefined', () => { + expect(Object.assign).toBeDefined() + const it = new DOMParser() + + expect(it.assign).toBe(Object.assign) + }) + + test('should use `conventions.assign` when `Object.assign` is undefined', () => { + Object.assign = undefined // is reset by afterEach + + const it = new DOMParser() + + expect(it.assign).toBe(assign) + }) }) }) @@ -48,6 +162,39 @@ describe('DOMParser', () => { expect(actual).toBe(XML) }) + test("should create correct DOM for mimeType 'text/html'", () => { + const doc = new DOMParser().parseFromString( + '', + MIME_TYPE.HTML + ) + expect(doc.type).toBe('html') + expect(doc.contentType).toBe(MIME_TYPE.HTML) + expect(doc.documentElement.namespaceURI).toBe(NAMESPACE.HTML) + expect(doc.documentElement.nodeName).toBe('HTML') + }) + + test("should create correct DOM for mimeType 'application/xhtml+xml'", () => { + const doc = new DOMParser().parseFromString( + '', + MIME_TYPE.XML_XHTML_APPLICATION + ) + expect(doc.type).toBe('xml') + expect(doc.contentType).toBe(MIME_TYPE.XML_XHTML_APPLICATION) + expect(doc.documentElement.namespaceURI).toBe(NAMESPACE.HTML) + expect(doc.documentElement.nodeName).toBe('HTML') + }) + + test("should create correct DOM for mimeType 'image/svg+xml'", () => { + const doc = new DOMParser().parseFromString( + '', + MIME_TYPE.XML_SVG_IMAGE + ) + expect(doc.type).toBe('xml') + expect(doc.contentType).toBe(MIME_TYPE.XML_SVG_IMAGE) + expect(doc.documentElement.namespaceURI).toBe(NAMESPACE.SVG) + expect(doc.documentElement.nodeName).toBe('svg') + }) + test('should provide access to textContent and attribute values', () => { // provides an executable example for https://github.com/xmldom/xmldom/issues/93 const XML = ` @@ -82,3 +229,39 @@ describe('DOMParser', () => { }) }) }) + +describe('DOMHandler', () => { + describe('startDocument', () => { + test('should create an XML document when mimeType option is not passed', () => { + const handler = new __DOMHandler() + expect(handler.mimeType).toBe(MIME_TYPE.XML_APPLICATION) + handler.startDocument() + expect(handler.doc.childNodes).toHaveLength(0) + expect(handler.doc.type).toBe('xml') + }) + + test.each([ + undefined, + MIME_TYPE.XML_APPLICATION, + MIME_TYPE.XML_XHTML_APPLICATION, + MIME_TYPE.XML_TEXT, + MIME_TYPE.XML_SVG_IMAGE, + ])( + 'should create an XML document when mimeType option is %s', + (mimeType) => { + const handler = new __DOMHandler({ mimeType }) + expect(handler.mimeType).toBe(mimeType || MIME_TYPE.XML_APPLICATION) + handler.startDocument() + expect(handler.doc.childNodes).toHaveLength(0) + expect(handler.doc.type).toBe('xml') + } + ) + test("should create an HTML document when mimeType option is 'text/html'", () => { + const handler = new __DOMHandler({ mimeType: MIME_TYPE.HTML }) + expect(handler.mimeType).toBe(MIME_TYPE.HTML) + handler.startDocument() + expect(handler.doc.childNodes).toHaveLength(0) + expect(handler.doc.type).toBe('html') + }) + }) +}) diff --git a/test/dom/document.test.js b/test/dom/document.test.js index 525a69c8c..5258ef08b 100644 --- a/test/dom/document.test.js +++ b/test/dom/document.test.js @@ -2,6 +2,7 @@ const { getTestParser } = require('../get-test-parser') const { DOMImplementation } = require('../../lib/dom') +const { NAMESPACE } = require('../../lib/conventions') const INPUT = (first = '', second = '', third = '', fourth = '') => ` @@ -88,15 +89,92 @@ describe('Document.prototype', () => { }) }) }) - describe('doctype', () => { - it('should be added when passed to createDocument', () => { + describe('createElement', () => { + it('should create elements with exact cased name in an XML document', () => { const impl = new DOMImplementation() - const doctype = impl.createDocumentType('name') - const doc = impl.createDocument(null, undefined, doctype) + const doc = impl.createDocument(null, 'xml') - expect(doc.doctype === doctype).toBe(true) - expect(doctype.ownerDocument === doc).toBe(true) - expect(doc.firstChild === doctype).toBe(true) + const element = doc.createElement('XmL') + + expect(element.nodeName).toBe('XmL') + expect(element.localName).toBe(element.nodeName) + }) + it('should create elements with exact cased name in an XHTML document', () => { + const impl = new DOMImplementation() + const doc = impl.createDocument(NAMESPACE.HTML, '') + + const element = doc.createElement('XmL') + + expect(element.nodeName).toBe('XmL') + expect(element.localName).toBe(element.nodeName) + }) + it('should create elements with lower cased name in an HTML document', () => { + // https://dom.spec.whatwg.org/#dom-document-createelement + const impl = new DOMImplementation() + const doc = impl.createHTMLDocument(false) + + const element = doc.createElement('XmL') + + expect(element.localName).toBe('xml') + expect(element.nodeName).toBe('xml') + expect(element.tagName).toBe(element.nodeName) + }) + it('should create elements with no namespace in an XML document without default namespace', () => { + const impl = new DOMImplementation() + const doc = impl.createDocument(null, 'xml') + + const element = doc.createElement('XmL') + + expect(element.namespaceURI).toBeNull() + }) + it('should create elements with the HTML namespace in an XML document with HTML namespace', () => { + const impl = new DOMImplementation() + const doc = impl.createDocument(NAMESPACE.HTML, 'xml') + + const element = doc.createElement('XmL') + + expect(element.namespaceURI).toBe(NAMESPACE.HTML) + }) + it('should create elements with the HTML namespace in an HTML document', () => { + const impl = new DOMImplementation() + const doc = impl.createHTMLDocument() + + const element = doc.createElement('a') + + expect(element.namespaceURI).toBe(NAMESPACE.HTML) + }) + }) + describe('createAttribute', () => { + const NAME = 'NaMe' + test('should create name as passed in XML documents', () => { + const doc = new DOMImplementation().createDocument(null, '') + + const attr = doc.createAttribute(NAME) + + expect(attr.ownerDocument).toBe(doc) + expect(attr.name).toBe(NAME) + expect(attr.localName).toBe(NAME) + expect(attr.nodeName).toBe(NAME) + }) + test('should create name as passed in XHTML documents', () => { + const doc = new DOMImplementation().createDocument(NAMESPACE.HTML, '') + + const attr = doc.createAttribute(NAME) + + expect(attr.ownerDocument).toBe(doc) + expect(attr.name).toBe(NAME) + expect(attr.localName).toBe(NAME) + expect(attr.nodeName).toBe(NAME) + }) + test('should create lower cased name when passed in HTML document', () => { + const doc = new DOMImplementation().createHTMLDocument(false) + + const attr = doc.createAttribute(NAME) + + expect(attr.ownerDocument).toBe(doc) + expect(attr.name).toBe('name') + expect(attr.localName).toBe('name') + expect(attr.nodeName).toBe('name') }) }) }) diff --git a/test/dom/dom-implementation.test.js b/test/dom/dom-implementation.test.js index b30b5b94f..cec7101ce 100644 --- a/test/dom/dom-implementation.test.js +++ b/test/dom/dom-implementation.test.js @@ -7,6 +7,7 @@ const { Node, NodeList, } = require('../../lib/dom') +const { NAMESPACE, MIME_TYPE } = require('../../lib/conventions') const NAME = 'NAME' const PREFIX = 'PREFIX' @@ -40,6 +41,8 @@ describe('DOMImplementation', () => { expect(doc.doctype).toBe(null) expect(doc.childNodes).toBeInstanceOf(NodeList) expect(doc.documentElement).toBe(null) + expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION) + expect(doc.type).toBe('xml') }) it('should create a Document with only a doc type', () => { @@ -50,6 +53,8 @@ describe('DOMImplementation', () => { expect(doc.doctype).toBe(doctype) expect(doctype.ownerDocument).toBe(doc) expect(doc.childNodes.item(0)).toBe(doctype) + expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION) + expect(doc.type).toBe('xml') }) it('should create a Document with root element without a namespace', () => { @@ -65,6 +70,8 @@ describe('DOMImplementation', () => { expect(root.prefix).toBe(null) expect(root.localName).toBe(NAME) expect(doc.documentElement).toBe(root) + expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION) + expect(doc.type).toBe('xml') }) it('should create a Document with root element in a default namespace', () => { @@ -81,6 +88,8 @@ describe('DOMImplementation', () => { expect(root.tagName).toBe(NAME) expect(doc.documentElement).toBe(root) + expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION) + expect(doc.type).toBe('xml') }) it('should create a Document with root element in a named namespace', () => { @@ -98,6 +107,8 @@ describe('DOMImplementation', () => { expect(root.tagName).toBe(qualifiedName) expect(doc.documentElement).toBe(root) + expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION) + expect(doc.type).toBe('xml') }) it('should create a Document with root element in a named namespace', () => { @@ -115,6 +126,8 @@ describe('DOMImplementation', () => { expect(root.tagName).toBe(qualifiedName) expect(doc.documentElement).toBe(root) + expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION) + expect(doc.type).toBe('xml') }) it('should create a Document with namespaced root element and doctype', () => { @@ -137,6 +150,22 @@ describe('DOMImplementation', () => { expect(root.tagName).toBe(qualifiedName) expect(doc.documentElement).toBe(root) + expect(doc.contentType).toBe(MIME_TYPE.XML_APPLICATION) + expect(doc.type).toBe('xml') + }) + + it('should create SVG document from the SVG namespace', () => { + const impl = new DOMImplementation() + const doc = impl.createDocument(NAMESPACE.SVG, 'svg') + expect(doc.contentType).toBe(MIME_TYPE.XML_SVG_IMAGE) + expect(doc.type).toBe('xml') + }) + + it('should create XHTML document from the HTML namespace', () => { + const impl = new DOMImplementation() + const doc = impl.createDocument(NAMESPACE.HTML, 'svg') + expect(doc.contentType).toBe(MIME_TYPE.XML_XHTML_APPLICATION) + expect(doc.type).toBe('xml') }) }) @@ -162,4 +191,103 @@ describe('DOMImplementation', () => { expect(doctype.systemId).toBe('"SYSTEM"') }) }) + describe('createHTMLDocument', () => { + it('should create an empty HTML document without any elements', () => { + const impl = new DOMImplementation() + const doc = impl.createHTMLDocument(false) + + expect(doc.implementation).toBe(impl) + expect(doc.contentType).toBe(MIME_TYPE.HTML) + expect(doc.type).toBe('html') + expect(doc.childNodes.length).toBe(0) + expect(doc.doctype).toBeNull() + expect(doc.documentElement).toBeNull() + }) + it('should create an HTML document with minimum specified elements when title not provided', () => { + const impl = new DOMImplementation() + const doc = impl.createHTMLDocument() + + expect(doc.implementation).toBe(impl) + expect(doc.contentType).toBe(MIME_TYPE.HTML) + expect(doc.type).toBe('html') + + expect(doc.doctype).not.toBeNull() + expect(doc.doctype.name).toBe('html') + expect(doc.doctype.nodeName).toBe('html') + expect(doc.doctype.ownerDocument).toBe(doc) + expect(doc.childNodes.item(0)).toBe(doc.doctype) + expect(doc.firstChild).toBe(doc.doctype) + + expect(doc.documentElement).not.toBeNull() + expect(doc.documentElement.localName).toBe('html') + expect(doc.documentElement.nodeName).toBe('html') + expect(doc.documentElement.tagName).toBe(doc.documentElement.nodeName) + const htmlNode = doc.documentElement + expect(htmlNode.firstChild).not.toBeNull() + expect(htmlNode.firstChild.nodeName).toBe('head') + expect(htmlNode.firstChild.childNodes).toHaveLength(0) + + expect(htmlNode.lastChild).not.toBeNull() + expect(htmlNode.lastChild.nodeName).toBe('body') + expect(htmlNode.lastChild.childNodes).toHaveLength(0) + }) + it('should create an HTML document with specified elements including an empty title', () => { + const impl = new DOMImplementation() + const doc = impl.createHTMLDocument('') + + expect(doc.implementation).toBe(impl) + expect(doc.contentType).toBe(MIME_TYPE.HTML) + expect(doc.type).toBe('html') + + expect(doc.doctype).not.toBeNull() + expect(doc.doctype.name).toBe('html') + expect(doc.doctype.nodeName).toBe('html') + expect(doc.doctype.ownerDocument).toBe(doc) + expect(doc.childNodes.item(0)).toBe(doc.doctype) + expect(doc.firstChild).toBe(doc.doctype) + + expect(doc.documentElement).not.toBeNull() + expect(doc.documentElement.localName).toBe('html') + expect(doc.documentElement.nodeName).toBe('html') + expect(doc.documentElement.tagName).toBe(doc.documentElement.nodeName) + const htmlNode = doc.documentElement + + expect(htmlNode.firstChild).not.toBeNull() + expect(htmlNode.firstChild.nodeName).toBe('head') + const headNode = htmlNode.firstChild + + expect(headNode.firstChild).not.toBeNull() + expect(headNode.firstChild.nodeName).toBe('title') + expect(headNode.firstChild.firstChild).not.toBeNull() + expect(headNode.firstChild.firstChild.ownerDocument).toBe(doc) + expect(headNode.firstChild.firstChild.nodeType).toBe(Node.TEXT_NODE) + expect(headNode.firstChild.firstChild.nodeValue).toBe('') + }) + it('should create an HTML document with specified elements including an provided title', () => { + const impl = new DOMImplementation() + const doc = impl.createHTMLDocument('eltiT') + + expect(doc.implementation).toBe(impl) + expect(doc.contentType).toBe(MIME_TYPE.HTML) + expect(doc.type).toBe('html') + + expect(doc.documentElement).not.toBeNull() + expect(doc.documentElement.localName).toBe('html') + expect(doc.documentElement.nodeName).toBe('html') + expect(doc.documentElement.tagName).toBe(doc.documentElement.nodeName) + const htmlNode = doc.documentElement + + expect(htmlNode.firstChild).not.toBeNull() + expect(htmlNode.firstChild.nodeName).toBe('head') + const headNode = htmlNode.firstChild + + expect(headNode.firstChild).not.toBeNull() + expect(headNode.firstChild.nodeName).toBe('title') + + expect(headNode.firstChild.firstChild).not.toBeNull() + expect(headNode.firstChild.firstChild.ownerDocument).toBe(doc) + expect(headNode.firstChild.firstChild.nodeType).toBe(Node.TEXT_NODE) + expect(headNode.firstChild.firstChild.nodeValue).toBe('eltiT') + }) + }) }) diff --git a/test/dom/element.test.js b/test/dom/element.test.js index 77c68ab2f..bf67a5f22 100644 --- a/test/dom/element.test.js +++ b/test/dom/element.test.js @@ -1,15 +1,58 @@ 'use strict' const { DOMParser, DOMImplementation, XMLSerializer } = require('../../lib') +const { MIME_TYPE, NAMESPACE } = require('../../lib/conventions') const { Element } = require('../../lib/dom') describe('Document', () => { // See: http://jsfiddle.net/bigeasy/ShcXP/1/ describe('getElementsByTagName', () => { - it('should return the correct number of elements', () => { - const doc = new DOMParser().parseFromString('') - expect(doc.getElementsByTagName('*')).toHaveLength(2) - expect(doc.documentElement.getElementsByTagName('*')).toHaveLength(1) + it('should return the correct number of elements in XML documents', () => { + const doc = new DOMParser().parseFromString( + ` + Title + +

+
+ +
` + ) + expect(doc.getElementsByTagName('*')).toHaveLength(8) + expect(doc.documentElement.getElementsByTagName('*')).toHaveLength(7) + expect(doc.getElementsByTagName('div')).toHaveLength(2) + expect(doc.documentElement.getElementsByTagName('div')).toHaveLength(2) + + // in HTML documents inside the HTML namespace case doesn't have to match, + // this is not an HTML document, so no div will be found, + // not even the second one inside the HTML namespace + expect(doc.getElementsByTagName('DIV')).toHaveLength(0) + expect(doc.documentElement.getElementsByTagName('DIV')).toHaveLength(0) + }) + + it('should return the correct number of elements in HTML documents', () => { + const doc = new DOMParser().parseFromString( + ` + Title + +

+
+ + `, + MIME_TYPE.HTML + ) + expect(doc.getElementsByTagName('*')).toHaveLength(8) + expect(doc.documentElement.getElementsByTagName('*')).toHaveLength(7) + expect(doc.getElementsByTagName('div')).toHaveLength(2) + expect(doc.documentElement.getElementsByTagName('div')).toHaveLength(2) + + // in HTML documents inside the HTML namespace case doesn't have to match, + // but the second one is not in the HTML namespace + const documentDIVs = doc.getElementsByTagName('DIV') + expect(documentDIVs).toHaveLength(1) + expect(documentDIVs.item(0).getAttribute('id')).toBe('4') + const elementDIVs = doc.documentElement.getElementsByTagName('DIV') + expect(elementDIVs).toHaveLength(1) + expect(elementDIVs.item(0).getAttribute('id')).toBe('4') }) it('should support API on element (this test needs to be split)', () => { @@ -166,16 +209,6 @@ describe('Document', () => { expect(doc.documentElement.toString()).toBe('bye') }) - describe('createElement', () => { - it('should set localName', () => { - const doc = new DOMImplementation().createDocument(null, 'test', null) - - const elem = doc.createElement('foo') - - expect(elem.localName === 'foo') - }) - }) - it('appendElement and removeElement', () => { const dom = new DOMParser().parseFromString(``) const doc = dom.documentElement @@ -212,3 +245,65 @@ describe('Document', () => { xit('self append failed', () => {}) }) + +describe('Element', () => { + const ATTR_MIXED_CASE = 'AttR' + const ATTR_LOWER_CASE = 'attr' + const VALUE = '2039e2dk' + describe('setAttribute', () => { + test.each([null, NAMESPACE.HTML])( + 'should set attribute as is in XML document with namespace %s', + (ns) => { + const doc = new DOMImplementation().createDocument(ns, 'xml') + + doc.documentElement.setAttribute(ATTR_MIXED_CASE, VALUE) + + expect(doc.documentElement.attributes).toHaveLength(1) + expect(doc.documentElement.attributes.item(0)).toMatchObject({ + name: ATTR_MIXED_CASE, + value: VALUE, + }) + expect(doc.documentElement.hasAttribute(ATTR_MIXED_CASE)).toBe(true) + expect(doc.documentElement.hasAttribute(ATTR_LOWER_CASE)).toBe(false) + } + ) + test('should set attribute lower cased in HTML document', () => { + const doc = new DOMImplementation().createHTMLDocument() + + doc.documentElement.setAttribute(ATTR_MIXED_CASE, VALUE) + + expect(doc.documentElement.attributes).toHaveLength(1) + expect(doc.documentElement.attributes.item(0)).toMatchObject({ + name: ATTR_LOWER_CASE, + value: VALUE, + }) + // the attribute is accessible with the lower cased name + expect(doc.documentElement.hasAttribute(ATTR_LOWER_CASE)).toBe(true) + // and with the original name (and any other one that is the same lower case name) + expect(doc.documentElement.hasAttribute(ATTR_MIXED_CASE)).toBe(true) + // the value is the same for "both" attribute names + expect(doc.documentElement.getAttribute(ATTR_MIXED_CASE)).toBe( + doc.documentElement.getAttribute(ATTR_LOWER_CASE) + ) + // since it's the same node it resolves to + expect(doc.documentElement.getAttributeNode(ATTR_MIXED_CASE)).toBe( + doc.documentElement.getAttributeNode(ATTR_LOWER_CASE) + ) + }) + test('should set attribute as is in HTML document with different namespace', () => { + const doc = new DOMImplementation().createHTMLDocument() + const nameSpacedElement = doc.createElementNS(NAMESPACE.SVG, 'svg') + expect(nameSpacedElement.namespaceURI).toBe(NAMESPACE.SVG) + + nameSpacedElement.setAttribute(ATTR_MIXED_CASE, VALUE) + + expect(nameSpacedElement.attributes).toHaveLength(1) + expect(nameSpacedElement.attributes.item(0)).toMatchObject({ + name: ATTR_MIXED_CASE, + value: VALUE, + }) + expect(nameSpacedElement.hasAttribute(ATTR_MIXED_CASE)).toBe(true) + expect(doc.documentElement.hasAttribute(ATTR_LOWER_CASE)).toBe(false) + }) + }) +}) diff --git a/test/dom/ns-test.test.js b/test/dom/ns-test.test.js index 5cad635a8..647e35dd9 100644 --- a/test/dom/ns-test.test.js +++ b/test/dom/ns-test.test.js @@ -17,7 +17,7 @@ describe('XML Namespace Parse', () => { const el = doc.getElementsByTagName('c:var')[0] expect(el.namespaceURI).toBe('http://www.xidea.org/lite/core') expect(doc.toString()).toBe( - '' + '' ) }) @@ -25,12 +25,7 @@ describe('XML Namespace Parse', () => { const w3 = 'http://www.w3.org/1999/xhtml' const n1 = 'http://www.frankston.com/public' const n2 = 'http://rmf.vc/n2' - const hx = - '' + const hx = `` const doc = new DOMParser().parseFromString(hx, 'text/xml') const els = [].slice.call( @@ -39,17 +34,19 @@ describe('XML Namespace Parse', () => { for (let _i = 0, els_1 = els; _i < els_1.length; _i++) { const el = els_1[_i] - const te = doc.createElementNS(n1, 'test') - te.setAttributeNS(n1, 'bar', 'valx') - expect(te.toString()).toBe('') - el.appendChild(te) - const tx = doc.createElementNS(n2, 'test') - tx.setAttributeNS(n2, 'bar', 'valx') - expect(tx.toString()).toBe('') - el.appendChild(tx) + const n1_test = doc.createElementNS(n1, 'test') + n1_test.setAttribute('xmlns', n1) + n1_test.setAttributeNS(n1, 'bar', 'valx') + expect(n1_test.toString()).toBe('') + el.appendChild(n1_test) + const n2_test = doc.createElementNS(n2, 'test') + n2_test.setAttribute('xmlns', n2) + n2_test.setAttributeNS(n2, 'bar', 'valx') + expect(n2_test.toString()).toBe('') + el.appendChild(n2_test) } expect(doc.toString()).toBe( - '' + '' ) }) }) diff --git a/test/dom/serializer.test.js b/test/dom/serializer.test.js index 7a41c9784..11ae353e4 100644 --- a/test/dom/serializer.test.js +++ b/test/dom/serializer.test.js @@ -83,19 +83,19 @@ describe('XML Serializer', () => { const doc = new DOMParser().parseFromString(str, MIME_TYPE.HTML) const child = doc.createElementNS('AAA', 'child') - expect(new XMLSerializer().serializeToString(child, true)).toBe( + expect(new XMLSerializer().serializeToString(child)).toBe( '' ) doc.documentElement.appendChild(child) - expect(new XMLSerializer().serializeToString(doc, true)).toBe( + expect(new XMLSerializer().serializeToString(doc)).toBe( '' ) const nested = doc.createElementNS('AAA', 'nested') - expect(new XMLSerializer().serializeToString(nested, true)).toBe( + expect(new XMLSerializer().serializeToString(nested)).toBe( '' ) child.appendChild(nested) - expect(new XMLSerializer().serializeToString(doc, true)).toBe( + expect(new XMLSerializer().serializeToString(doc)).toBe( '' ) }) diff --git a/test/error/__snapshots__/reported-levels.test.js.snap b/test/error/__snapshots__/reported-levels.test.js.snap index c32c545ab..8ee742c6f 100644 --- a/test/error/__snapshots__/reported-levels.test.js.snap +++ b/test/error/__snapshots__/reported-levels.test.js.snap @@ -352,20 +352,6 @@ Array [ ] `; -exports[`WF_AttributeMissingValue with mimeType text/html should be reported as warning 1`] = ` -Array [ - "[xmldom warning] attribute \\"attr\\" missed value!! \\"attr\\" instead!! -@#[line:1,col:1]", -] -`; - -exports[`WF_AttributeMissingValue with mimeType text/html should escalate Error thrown in errorHandler.warning to errorHandler.error 1`] = ` -Array [ - "[xmldom warning] attribute \\"attr\\" missed value!! \\"attr\\" instead!!||@#[line:1,col:1] - at parseElementStartPart (lib/sax.js:#15)", -] -`; - exports[`WF_AttributeMissingValue with mimeType text/xml should be reported as warning 1`] = ` Array [ "[xmldom warning] attribute \\"attr\\" missed value!! \\"attr\\" instead!! @@ -380,22 +366,6 @@ Array [ ] `; -exports[`WF_AttributeMissingValue2 with mimeType text/html should be reported as warning 1`] = ` -Array [ - "[xmldom warning] attribute \\"attr\\" missed value!! \\"attr\\" instead2!! -@#[line:1,col:1]", - "[xmldom warning] attribute \\"attr2\\" missed value!! \\"attr2\\" instead!! -@#[line:1,col:1]", -] -`; - -exports[`WF_AttributeMissingValue2 with mimeType text/html should escalate Error thrown in errorHandler.warning to errorHandler.error 1`] = ` -Array [ - "[xmldom warning] attribute \\"attr\\" missed value!! \\"attr\\" instead2!!||@#[line:1,col:1] - at parseElementStartPart (lib/sax.js:#18)", -] -`; - exports[`WF_AttributeMissingValue2 with mimeType text/xml should be reported as warning 1`] = ` Array [ "[xmldom warning] attribute \\"attr\\" missed value!! \\"attr\\" instead2!! diff --git a/test/error/__snapshots__/xml-error.test.js.snap b/test/error/__snapshots__/xml-error.test.js.snap index 5a9ac6e31..f80ac5998 100644 --- a/test/error/__snapshots__/xml-error.test.js.snap +++ b/test/error/__snapshots__/xml-error.test.js.snap @@ -48,7 +48,7 @@ Object { --> - + ", diff --git a/test/error/reported.js b/test/error/reported.js index ec4f04814..1944ecf4f 100644 --- a/test/error/reported.js +++ b/test/error/reported.js @@ -223,6 +223,7 @@ const REPORTED = { source: '', level: 'warning', match: (msg) => /missed value/.test(msg) && /instead!!/.test(msg), + skippedInHtml: true, }, /** * Triggered by lib/sax.js:376 @@ -236,6 +237,7 @@ const REPORTED = { source: '', level: 'warning', match: (msg) => /missed value/.test(msg) && /instead2!!/.test(msg), + skippedInHtml: true, }, } diff --git a/test/error/xml-reader-dom-handler-errors.test.js b/test/error/xml-reader-dom-handler-errors.test.js index cb410e6f0..5aea554df 100644 --- a/test/error/xml-reader-dom-handler-errors.test.js +++ b/test/error/xml-reader-dom-handler-errors.test.js @@ -56,26 +56,25 @@ function noop() {} * * The `methods` property provides the list of all mocks. */ -class StubDOMHandler extends __DOMHandler { - constructor(throwingMethod, ErrorClass) { - super() - this.methods = [] - DOMHandlerMethods.forEach((method) => { - const impl = jest.fn( - method === throwingMethod - ? () => { - throw new (ErrorClass || ParseError)( - `StubDOMHandler throwing in ${throwingMethod}` - ) - } - : noop - ) - impl.mockName(method) - this[method] = impl - this.methods.push(impl) - }) - } +function StubDOMHandlerWith(throwingMethod, ErrorClass) { + class StubDOMHandler extends __DOMHandler {} + StubDOMHandler.methods = DOMHandlerMethods.map((method) => { + const impl = jest.fn( + method === throwingMethod + ? () => { + throw new (ErrorClass || ParseError)( + `StubDOMHandler throwing in ${throwingMethod}` + ) + } + : noop() + ) + impl.mockName(method) + StubDOMHandler.prototype[method] = impl + return impl + }) + return StubDOMHandler } + /** * This sample is triggering all method calls from XMLReader to DOMHandler at least once. * This is verified in a test. @@ -102,13 +101,13 @@ const ALL_METHODS = ` describe('methods called in DOMHandler', () => { it('should call "all possible" methods when using StubDOMHandler', () => { - const domBuilder = new StubDOMHandler() - const parser = new DOMParser({ domBuilder, locator: {} }) - expect(domBuilder.methods).toHaveLength(DOMHandlerMethods.length) + const domHandler = StubDOMHandlerWith() + const parser = new DOMParser({ domHandler, locator: true }) + expect(domHandler.methods).toHaveLength(DOMHandlerMethods.length) parser.parseFromString(ALL_METHODS) - const uncalledMethodNames = domBuilder.methods + const uncalledMethodNames = domHandler.methods .filter((m) => m.mock.calls.length === 0) .map((m) => m.getMockName()) expect(uncalledMethodNames).toEqual([...UNCALLED_METHODS.values()].sort()) @@ -117,8 +116,8 @@ describe('methods called in DOMHandler', () => { 'when DOMHandler.%s throws', (throwing) => { it('should not catch ParserError', () => { - const domBuilder = new StubDOMHandler(throwing, ParseError) - const parser = new DOMParser({ domBuilder, locator: {} }) + const domHandler = StubDOMHandlerWith(throwing, ParseError) + const parser = new DOMParser({ domHandler, locator: true }) expect(() => parser.parseFromString(ALL_METHODS)).toThrow(ParseError) }) @@ -126,8 +125,8 @@ describe('methods called in DOMHandler', () => { it(`${ isUncaughtMethod ? 'does not' : 'should' } catch other Error`, () => { - const domBuilder = new StubDOMHandler(throwing, Error) - const parser = new DOMParser({ domBuilder, locator: {} }) + const domHandler = StubDOMHandlerWith(throwing, Error) + const parser = new DOMParser({ domHandler, locator: true }) if (isUncaughtMethod) { expect(() => parser.parseFromString(ALL_METHODS)).toThrow() diff --git a/test/get-test-parser.js b/test/get-test-parser.js index 59bdbd93e..ee8e0666d 100644 --- a/test/get-test-parser.js +++ b/test/get-test-parser.js @@ -14,18 +14,17 @@ const { DOMParser } = require('../lib/dom-parser') * - `errors`: the object for the `errorHandler` to use, * is also returned with the same name for later assertions, * default is an empty object - * - `locator`: The `locator` to pass to DOMParser constructor options, - * default is an empty object + * - `locator`: Whether to record node locations in the XML string, default is true * * @param options {{ * errorHandler?: function (key: ErrorLevel, msg: string) * | Partial>, * errors?: Partial>, - * locator?: Object + * locator?: boolean * }} * @returns {{parser: DOMParser, errors: Partial>}} */ -function getTestParser({ errorHandler, errors = {}, locator = {} } = {}) { +function getTestParser({ errorHandler, errors = {}, locator = true } = {}) { errorHandler = errorHandler || ((key, msg) => { diff --git a/test/html/__snapshots__/normalize.test.js.snap b/test/html/__snapshots__/normalize.test.js.snap index d23c3fad9..b711682f2 100644 --- a/test/html/__snapshots__/normalize.test.js.snap +++ b/test/html/__snapshots__/normalize.test.js.snap @@ -19,8 +19,6 @@ Object { ], "warning": Array [ "[xmldom warning] attribute \\"&\\" missed quot(\\")!! -@#[line:1,col:1]", - "[xmldom warning] attribute \\"b\\" missed value!! \\"b\\" instead!! @#[line:1,col:1]", ], } @@ -31,10 +29,6 @@ Object { "actual": "
", "warning": Array [ "[xmldom warning] attribute \\"&\\" missed quot(\\")!! -@#[line:1,col:1]", - "[xmldom warning] attribute \\"bb\\" missed value!! \\"bb\\" instead2!! -@#[line:1,col:1]", - "[xmldom warning] attribute \\"c\\" missed value!! \\"c\\" instead2!! @#[line:1,col:1]", "[xmldom warning] attribute \\"123&&456\\" missed quot(\\")! @#[line:1,col:1]", @@ -110,6 +104,24 @@ Object { } `; +exports[`html normalizer text/html: script 1`] = ` +Object { + "actual": "", +} +`; + +exports[`html normalizer text/html: script 1`] = ` +Object { + "actual": "", +} +`; + +exports[`html normalizer text/html: script 1`] = ` +Object { + "actual": "", +} +`; + exports[`html normalizer text/html: script ", @@ -134,6 +146,42 @@ Object { } `; +exports[`html normalizer text/html: script
  • abc
  • def
1`] = ` +Object { + "actual": "
  • abc
  • def
", +} +`; + +exports[`html normalizer text/xml: script 1`] = ` +Object { + "actual": "", + "warning": Array [ + "[xmldom warning] attribute \\"disabled\\" missed value!! \\"disabled\\" instead!! +@#[line:1,col:1]", + ], +} +`; + +exports[`html normalizer text/xml: script 1`] = ` +Object { + "actual": "", + "warning": Array [ + "[xmldom warning] attribute \\"checked\\" missed value!! \\"checked\\" instead!! +@#[line:1,col:1]", + ], +} +`; + +exports[`html normalizer text/xml: script 1`] = ` +Object { + "actual": "