ref: 3ce4cfc9371c16b2ac4ac167668122fca13cc850
dir: /domino-lib/Document.js/
"use strict"; module.exports = Document; var Node = require('./Node'); var NodeList = require('./NodeList'); var ContainerNode = require('./ContainerNode'); var Element = require('./Element'); var Text = require('./Text'); var Comment = require('./Comment'); var Event = require('./Event'); var DocumentFragment = require('./DocumentFragment'); var ProcessingInstruction = require('./ProcessingInstruction'); var DOMImplementation = require('./DOMImplementation'); var TreeWalker = require('./TreeWalker'); var NodeIterator = require('./NodeIterator'); var NodeFilter = require('./NodeFilter'); var URL = require('./URL'); var select = require('./select'); var events = require('./events'); var xml = require('./xmlnames'); var html = require('./htmlelts'); var svg = require('./svg'); var utils = require('./utils'); var MUTATE = require('./MutationConstants'); var NAMESPACE = utils.NAMESPACE; var isApiWritable = require("./config").isApiWritable; function Document(isHTML, address) { ContainerNode.call(this); this.nodeType = Node.DOCUMENT_NODE; this.isHTML = isHTML; this._address = address || 'about:blank'; this.readyState = 'loading'; this.implementation = new DOMImplementation(this); // DOMCore says that documents are always associated with themselves this.ownerDocument = null; // ... but W3C tests expect null this._contentType = isHTML ? 'text/html' : 'application/xml'; // These will be initialized by our custom versions of // appendChild and insertBefore that override the inherited // Node methods. // XXX: override those methods! this.doctype = null; this.documentElement = null; // "Associated inert template document" this._templateDocCache = null; // List of active NodeIterators, see NodeIterator#_preremove() this._nodeIterators = null; // Documents are always rooted, by definition this._nid = 1; this._nextnid = 2; // For numbering children of the document this._nodes = [null, this]; // nid to node map // This maintains the mapping from element ids to element nodes. // We may need to update this mapping every time a node is rooted // or uprooted, and any time an attribute is added, removed or changed // on a rooted element. this.byId = Object.create(null); // This property holds a monotonically increasing value akin to // a timestamp used to record the last modification time of nodes // and their subtrees. See the lastModTime attribute and modify() // method of the Node class. And see FilteredElementList for an example // of the use of lastModTime this.modclock = 0; } // Map from lowercase event category names (used as arguments to // createEvent()) to the property name in the impl object of the // event constructor. var supportedEvents = { event: 'Event', customevent: 'CustomEvent', uievent: 'UIEvent', mouseevent: 'MouseEvent' }; // Certain arguments to document.createEvent() must be treated specially var replacementEvent = { events: 'event', htmlevents: 'event', mouseevents: 'mouseevent', mutationevents: 'mutationevent', uievents: 'uievent' }; var mirrorAttr = function(f, name, defaultValue) { return { get: function() { var o = f.call(this); if (o) { return o[name]; } return defaultValue; }, set: function(value) { var o = f.call(this); if (o) { o[name] = value; } }, }; }; /** @spec https://dom.spec.whatwg.org/#validate-and-extract */ function validateAndExtract(namespace, qualifiedName) { var prefix, localName, pos; if (namespace==='') { namespace = null; } // See https://github.com/whatwg/dom/issues/671 // and https://github.com/whatwg/dom/issues/319 if (!xml.isValidQName(qualifiedName)) { utils.InvalidCharacterError(); } prefix = null; localName = qualifiedName; pos = qualifiedName.indexOf(':'); if (pos >= 0) { prefix = qualifiedName.substring(0, pos); localName = qualifiedName.substring(pos+1); } if (prefix !== null && namespace === null) { utils.NamespaceError(); } if (prefix === 'xml' && namespace !== NAMESPACE.XML) { utils.NamespaceError(); } if ((prefix === 'xmlns' || qualifiedName === 'xmlns') && namespace !== NAMESPACE.XMLNS) { utils.NamespaceError(); } if (namespace === NAMESPACE.XMLNS && !(prefix==='xmlns' || qualifiedName==='xmlns')) { utils.NamespaceError(); } return { namespace: namespace, prefix: prefix, localName: localName }; } Document.prototype = Object.create(ContainerNode.prototype, { // This method allows dom.js to communicate with a renderer // that displays the document in some way // XXX: I should probably move this to the window object _setMutationHandler: { value: function(handler) { this.mutationHandler = handler; }}, // This method allows dom.js to receive event notifications // from the renderer. // XXX: I should probably move this to the window object _dispatchRendererEvent: { value: function(targetNid, type, details) { var target = this._nodes[targetNid]; if (!target) return; target._dispatchEvent(new Event(type, details), true); }}, nodeName: { value: '#document'}, nodeValue: { get: function() { return null; }, set: function() {} }, // XXX: DOMCore may remove documentURI, so it is NYI for now documentURI: { get: function() { return this._address; }, set: utils.nyi.bind(this, 'set documentURI') }, compatMode: { get: function() { // The _quirks property is set by the HTML parser return this._quirks ? 'BackCompat' : 'CSS1Compat'; }}, createTextNode: { value: function(data) { return new Text(this, String(data)); }}, createComment: { value: function(data) { return new Comment(this, data); }}, createDocumentFragment: { value: function() { return new DocumentFragment(this); }}, createProcessingInstruction: { value: function(target, data) { if (!xml.isValidName(target) || data.indexOf('?>') !== -1) utils.InvalidCharacterError(); return new ProcessingInstruction(this, target, data); }}, createAttribute: { value: function(localName) { localName = String(localName); if (!xml.isValidName(localName)) utils.InvalidCharacterError(); if (this.isHTML) { localName = utils.toASCIILowerCase(localName); } return new Element._Attr(null, localName, null, null, ''); }}, createAttributeNS: { value: function(namespace, qualifiedName) { // Convert parameter types according to WebIDL namespace = (namespace === null || namespace === undefined || namespace === '') ? null : String(namespace); qualifiedName = String(qualifiedName); var ve = validateAndExtract(namespace, qualifiedName); return new Element._Attr(null, ve.localName, ve.prefix, ve.namespace, ''); }}, createElement: { value: function(localName) { localName = String(localName); if (!xml.isValidName(localName)) utils.InvalidCharacterError(); // Per spec, namespace should be HTML namespace if "context object is // an HTML document or context object's content type is // "application/xhtml+xml", and null otherwise. if (this.isHTML) { if (/[A-Z]/.test(localName)) localName = utils.toASCIILowerCase(localName); return html.createElement(this, localName, null); } else if (this.contentType === 'application/xhtml+xml') { return html.createElement(this, localName, null); } else { return new Element(this, localName, null, null); } }, writable: isApiWritable }, createElementNS: { value: function(namespace, qualifiedName) { // Convert parameter types according to WebIDL namespace = (namespace === null || namespace === undefined || namespace === '') ? null : String(namespace); qualifiedName = String(qualifiedName); var ve = validateAndExtract(namespace, qualifiedName); return this._createElementNS(ve.localName, ve.namespace, ve.prefix); }, writable: isApiWritable }, // This is used directly by HTML parser, which allows it to create // elements with localNames containing ':' and non-default namespaces _createElementNS: { value: function(localName, namespace, prefix) { if (namespace === NAMESPACE.HTML) { return html.createElement(this, localName, prefix); } else if (namespace === NAMESPACE.SVG) { return svg.createElement(this, localName, prefix); } return new Element(this, localName, namespace, prefix); }}, createEvent: { value: function createEvent(interfaceName) { interfaceName = interfaceName.toLowerCase(); var name = replacementEvent[interfaceName] || interfaceName; var constructor = events[supportedEvents[name]]; if (constructor) { var e = new constructor(); e._initialized = false; return e; } else { utils.NotSupportedError(); } }}, // See: http://www.w3.org/TR/dom/#dom-document-createtreewalker createTreeWalker: {value: function (root, whatToShow, filter) { if (!root) { throw new TypeError("root argument is required"); } if (!(root instanceof Node)) { throw new TypeError("root not a node"); } whatToShow = whatToShow === undefined ? NodeFilter.SHOW_ALL : (+whatToShow); filter = filter === undefined ? null : filter; return new TreeWalker(root, whatToShow, filter); }}, // See: http://www.w3.org/TR/dom/#dom-document-createnodeiterator createNodeIterator: {value: function (root, whatToShow, filter) { if (!root) { throw new TypeError("root argument is required"); } if (!(root instanceof Node)) { throw new TypeError("root not a node"); } whatToShow = whatToShow === undefined ? NodeFilter.SHOW_ALL : (+whatToShow); filter = filter === undefined ? null : filter; return new NodeIterator(root, whatToShow, filter); }}, _attachNodeIterator: { value: function(ni) { // XXX ideally this should be a weak reference from Document to NodeIterator if (!this._nodeIterators) { this._nodeIterators = []; } this._nodeIterators.push(ni); }}, _detachNodeIterator: { value: function(ni) { // ni should always be in list of node iterators var idx = this._nodeIterators.indexOf(ni); this._nodeIterators.splice(idx, 1); }}, _preremoveNodeIterators: { value: function(toBeRemoved) { if (this._nodeIterators) { this._nodeIterators.forEach(function(ni) { ni._preremove(toBeRemoved); }); } }}, // Maintain the documentElement and // doctype properties of the document. Each of the following // methods chains to the Node implementation of the method // to do the actual inserting, removal or replacement. _updateDocTypeElement: { value: function _updateDocTypeElement() { this.doctype = this.documentElement = null; for (var kid = this.firstChild; kid !== null; kid = kid.nextSibling) { if (kid.nodeType === Node.DOCUMENT_TYPE_NODE) this.doctype = kid; else if (kid.nodeType === Node.ELEMENT_NODE) this.documentElement = kid; } }}, insertBefore: { value: function insertBefore(child, refChild) { Node.prototype.insertBefore.call(this, child, refChild); this._updateDocTypeElement(); return child; }}, replaceChild: { value: function replaceChild(node, child) { Node.prototype.replaceChild.call(this, node, child); this._updateDocTypeElement(); return child; }}, removeChild: { value: function removeChild(child) { Node.prototype.removeChild.call(this, child); this._updateDocTypeElement(); return child; }}, getElementById: { value: function(id) { var n = this.byId[id]; if (!n) return null; if (n instanceof MultiId) { // there was more than one element with this id return n.getFirst(); } return n; }}, _hasMultipleElementsWithId: { value: function(id) { // Used internally by querySelectorAll optimization return (this.byId[id] instanceof MultiId); }}, // Just copy this method from the Element prototype getElementsByName: { value: Element.prototype.getElementsByName }, getElementsByTagName: { value: Element.prototype.getElementsByTagName }, getElementsByTagNameNS: { value: Element.prototype.getElementsByTagNameNS }, getElementsByClassName: { value: Element.prototype.getElementsByClassName }, adoptNode: { value: function adoptNode(node) { if (node.nodeType === Node.DOCUMENT_NODE) utils.NotSupportedError(); if (node.nodeType === Node.ATTRIBUTE_NODE) { return node; } if (node.parentNode) node.parentNode.removeChild(node); if (node.ownerDocument !== this) recursivelySetOwner(node, this); return node; }}, importNode: { value: function importNode(node, deep) { return this.adoptNode(node.cloneNode(deep)); }, writable: isApiWritable }, // The following attributes and methods are from the HTML spec origin: { get: function origin() { return null; } }, characterSet: { get: function characterSet() { return "UTF-8"; } }, contentType: { get: function contentType() { return this._contentType; } }, URL: { get: function URL() { return this._address; } }, domain: { get: utils.nyi.bind(this, 'domain'), set: utils.nyi.bind(this, 'domain') }, referrer: { get: utils.nyi.bind(this, 'referrer') }, cookie: { get: utils.nyi.bind(this, 'cookie get'), set: utils.nyi.bind(this, 'cookie set') }, lastModified: { get: utils.nyi.bind(this, 'lastModified get') }, location: { get: function() { return this.defaultView ? this.defaultView.location : null; // gh #75 }, set: utils.nyi.bind(this, 'location set') }, _titleElement: { get: function() { // The title element of a document is the first title element in the // document in tree order, if there is one, or null otherwise. return this.getElementsByTagName('title').item(0) || null; } }, title: { get: function() { var elt = this._titleElement; // The child text content of the title element, or '' if null. var value = elt ? elt.textContent : ''; // Strip and collapse whitespace in value return value.replace(/[ \t\n\r\f]+/g, ' ').replace(/(^ )|( $)/g, ''); }, set: function(value) { var elt = this._titleElement; var head = this.head; if (!elt && !head) { return; /* according to spec */ } if (!elt) { elt = this.createElement('title'); head.appendChild(elt); } elt.textContent = value; } }, dir: mirrorAttr(function() { var htmlElement = this.documentElement; if (htmlElement && htmlElement.tagName === 'HTML') { return htmlElement; } }, 'dir', ''), fgColor: mirrorAttr(function() { return this.body; }, 'text', ''), linkColor: mirrorAttr(function() { return this.body; }, 'link', ''), vlinkColor: mirrorAttr(function() { return this.body; }, 'vLink', ''), alinkColor: mirrorAttr(function() { return this.body; }, 'aLink', ''), bgColor: mirrorAttr(function() { return this.body; }, 'bgColor', ''), // Historical aliases of Document#characterSet charset: { get: function() { return this.characterSet; } }, inputEncoding: { get: function() { return this.characterSet; } }, scrollingElement: { get: function() { return this._quirks ? this.body : this.documentElement; } }, // Return the first <body> child of the document element. // XXX For now, setting this attribute is not implemented. body: { get: function() { return namedHTMLChild(this.documentElement, 'body'); }, set: utils.nyi }, // Return the first <head> child of the document element. head: { get: function() { return namedHTMLChild(this.documentElement, 'head'); }}, images: { get: utils.nyi }, embeds: { get: utils.nyi }, plugins: { get: utils.nyi }, links: { get: utils.nyi }, forms: { get: utils.nyi }, scripts: { get: utils.nyi }, applets: { get: function() { return []; } }, activeElement: { get: function() { return null; } }, innerHTML: { get: function() { return this.serialize(); }, set: utils.nyi }, outerHTML: { get: function() { return this.serialize(); }, set: utils.nyi }, write: { value: function(args) { if (!this.isHTML) utils.InvalidStateError(); // XXX: still have to implement the ignore part if (!this._parser /* && this._ignore_destructive_writes > 0 */ ) return; if (!this._parser) { // XXX call document.open, etc. } var s = arguments.join(''); // If the Document object's reload override flag is set, then // append the string consisting of the concatenation of all the // arguments to the method to the Document's reload override // buffer. // XXX: don't know what this is about. Still have to do it // If there is no pending parsing-blocking script, have the // tokenizer process the characters that were inserted, one at a // time, processing resulting tokens as they are emitted, and // stopping when the tokenizer reaches the insertion point or when // the processing of the tokenizer is aborted by the tree // construction stage (this can happen if a script end tag token is // emitted by the tokenizer). // XXX: still have to do the above. Sounds as if we don't // always call parse() here. If we're blocked, then we just // insert the text into the stream but don't parse it reentrantly... // Invoke the parser reentrantly this._parser.parse(s); }}, writeln: { value: function writeln(args) { this.write(Array.prototype.join.call(arguments, '') + '\n'); }}, open: { value: function() { this.documentElement = null; }}, close: { value: function() { this.readyState = 'interactive'; this._dispatchEvent(new Event('readystatechange'), true); this._dispatchEvent(new Event('DOMContentLoaded'), true); this.readyState = 'complete'; this._dispatchEvent(new Event('readystatechange'), true); if (this.defaultView) { this.defaultView._dispatchEvent(new Event('load'), true); } }}, // Utility methods clone: { value: function clone() { var d = new Document(this.isHTML, this._address); d._quirks = this._quirks; d._contentType = this._contentType; return d; }}, // We need to adopt the nodes if we do a deep clone cloneNode: { value: function cloneNode(deep) { var clone = Node.prototype.cloneNode.call(this, false); if (deep) { for (var kid = this.firstChild; kid !== null; kid = kid.nextSibling) { clone._appendChild(clone.importNode(kid, true)); } } clone._updateDocTypeElement(); return clone; }}, isEqual: { value: function isEqual(n) { // Any two documents are shallowly equal. // Node.isEqualNode will also test the children return true; }}, // Implementation-specific function. Called when a text, comment, // or pi value changes. mutateValue: { value: function(node) { if (this.mutationHandler) { this.mutationHandler({ type: MUTATE.VALUE, target: node, data: node.data }); } }}, // Invoked when an attribute's value changes. Attr holds the new // value. oldval is the old value. Attribute mutations can also // involve changes to the prefix (and therefore the qualified name) mutateAttr: { value: function(attr, oldval) { // Manage id->element mapping for getElementsById() // XXX: this special case id handling should not go here, // but in the attribute declaration for the id attribute /* if (attr.localName === 'id' && attr.namespaceURI === null) { if (oldval) delId(oldval, attr.ownerElement); addId(attr.value, attr.ownerElement); } */ if (this.mutationHandler) { this.mutationHandler({ type: MUTATE.ATTR, target: attr.ownerElement, attr: attr }); } }}, // Used by removeAttribute and removeAttributeNS for attributes. mutateRemoveAttr: { value: function(attr) { /* * This is now handled in Attributes.js // Manage id to element mapping if (attr.localName === 'id' && attr.namespaceURI === null) { this.delId(attr.value, attr.ownerElement); } */ if (this.mutationHandler) { this.mutationHandler({ type: MUTATE.REMOVE_ATTR, target: attr.ownerElement, attr: attr }); } }}, // Called by Node.removeChild, etc. to remove a rooted element from // the tree. Only needs to generate a single mutation event when a // node is removed, but must recursively mark all descendants as not // rooted. mutateRemove: { value: function(node) { // Send a single mutation event if (this.mutationHandler) { this.mutationHandler({ type: MUTATE.REMOVE, target: node.parentNode, node: node }); } // Mark this and all descendants as not rooted recursivelyUproot(node); }}, // Called when a new element becomes rooted. It must recursively // generate mutation events for each of the children, and mark them all // as rooted. mutateInsert: { value: function(node) { // Mark node and its descendants as rooted recursivelyRoot(node); // Send a single mutation event if (this.mutationHandler) { this.mutationHandler({ type: MUTATE.INSERT, target: node.parentNode, node: node }); } }}, // Called when a rooted element is moved within the document mutateMove: { value: function(node) { if (this.mutationHandler) { this.mutationHandler({ type: MUTATE.MOVE, target: node }); } }}, // Add a mapping from id to n for n.ownerDocument addId: { value: function addId(id, n) { var val = this.byId[id]; if (!val) { this.byId[id] = n; } else { // TODO: Add a way to opt-out console warnings //console.warn('Duplicate element id ' + id); if (!(val instanceof MultiId)) { val = new MultiId(val); this.byId[id] = val; } val.add(n); } }}, // Delete the mapping from id to n for n.ownerDocument delId: { value: function delId(id, n) { var val = this.byId[id]; utils.assert(val); if (val instanceof MultiId) { val.del(n); if (val.length === 1) { // convert back to a single node this.byId[id] = val.downgrade(); } } else { this.byId[id] = undefined; } }}, _resolve: { value: function(href) { //XXX: Cache the URL return new URL(this._documentBaseURL).resolve(href); }}, _documentBaseURL: { get: function() { // XXX: This is not implemented correctly yet var url = this._address; if (url === 'about:blank') url = '/'; var base = this.querySelector('base[href]'); if (base) { return new URL(url).resolve(base.getAttribute('href')); } return url; // The document base URL of a Document object is the // absolute URL obtained by running these substeps: // Let fallback base url be the document's address. // If fallback base url is about:blank, and the // Document's browsing context has a creator browsing // context, then let fallback base url be the document // base URL of the creator Document instead. // If the Document is an iframe srcdoc document, then // let fallback base url be the document base URL of // the Document's browsing context's browsing context // container's Document instead. // If there is no base element that has an href // attribute, then the document base URL is fallback // base url; abort these steps. Otherwise, let url be // the value of the href attribute of the first such // element. // Resolve url relative to fallback base url (thus, // the base href attribute isn't affected by xml:base // attributes). // The document base URL is the result of the previous // step if it was successful; otherwise it is fallback // base url. }}, _templateDoc: { get: function() { if (!this._templateDocCache) { // "associated inert template document" var newDoc = new Document(this.isHTML, this._address); this._templateDocCache = newDoc._templateDocCache = newDoc; } return this._templateDocCache; }}, querySelector: { value: function(selector) { return select(selector, this)[0]; }}, querySelectorAll: { value: function(selector) { var nodes = select(selector, this); return nodes.item ? nodes : new NodeList(nodes); }} }); var eventHandlerTypes = [ 'abort', 'canplay', 'canplaythrough', 'change', 'click', 'contextmenu', 'cuechange', 'dblclick', 'drag', 'dragend', 'dragenter', 'dragleave', 'dragover', 'dragstart', 'drop', 'durationchange', 'emptied', 'ended', 'input', 'invalid', 'keydown', 'keypress', 'keyup', 'loadeddata', 'loadedmetadata', 'loadstart', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'mousewheel', 'pause', 'play', 'playing', 'progress', 'ratechange', 'readystatechange', 'reset', 'seeked', 'seeking', 'select', 'show', 'stalled', 'submit', 'suspend', 'timeupdate', 'volumechange', 'waiting', 'blur', 'error', 'focus', 'load', 'scroll' ]; // Add event handler idl attribute getters and setters to Document eventHandlerTypes.forEach(function(type) { // Define the event handler registration IDL attribute for this type Object.defineProperty(Document.prototype, 'on' + type, { get: function() { return this._getEventHandler(type); }, set: function(v) { this._setEventHandler(type, v); } }); }); function namedHTMLChild(parent, name) { if (parent && parent.isHTML) { for (var kid = parent.firstChild; kid !== null; kid = kid.nextSibling) { if (kid.nodeType === Node.ELEMENT_NODE && kid.localName === name && kid.namespaceURI === NAMESPACE.HTML) { return kid; } } } return null; } function root(n) { n._nid = n.ownerDocument._nextnid++; n.ownerDocument._nodes[n._nid] = n; // Manage id to element mapping if (n.nodeType === Node.ELEMENT_NODE) { var id = n.getAttribute('id'); if (id) n.ownerDocument.addId(id, n); // Script elements need to know when they're inserted // into the document if (n._roothook) n._roothook(); } } function uproot(n) { // Manage id to element mapping if (n.nodeType === Node.ELEMENT_NODE) { var id = n.getAttribute('id'); if (id) n.ownerDocument.delId(id, n); } n.ownerDocument._nodes[n._nid] = undefined; n._nid = undefined; } function recursivelyRoot(node) { root(node); // XXX: // accessing childNodes on a leaf node creates a new array the // first time, so be careful to write this loop so that it // doesn't do that. node is polymorphic, so maybe this is hard to // optimize? Try switching on nodeType? /* if (node.hasChildNodes()) { var kids = node.childNodes; for(var i = 0, n = kids.length; i < n; i++) recursivelyRoot(kids[i]); } */ if (node.nodeType === Node.ELEMENT_NODE) { for (var kid = node.firstChild; kid !== null; kid = kid.nextSibling) recursivelyRoot(kid); } } function recursivelyUproot(node) { uproot(node); for (var kid = node.firstChild; kid !== null; kid = kid.nextSibling) recursivelyUproot(kid); } function recursivelySetOwner(node, owner) { node.ownerDocument = owner; node._lastModTime = undefined; // mod times are document-based if (Object.prototype.hasOwnProperty.call(node, '_tagName')) { node._tagName = undefined; // Element subclasses might need to change case } for (var kid = node.firstChild; kid !== null; kid = kid.nextSibling) recursivelySetOwner(kid, owner); } // A class for storing multiple nodes with the same ID function MultiId(node) { this.nodes = Object.create(null); this.nodes[node._nid] = node; this.length = 1; this.firstNode = undefined; } // Add a node to the list, with O(1) time MultiId.prototype.add = function(node) { if (!this.nodes[node._nid]) { this.nodes[node._nid] = node; this.length++; this.firstNode = undefined; } }; // Remove a node from the list, with O(1) time MultiId.prototype.del = function(node) { if (this.nodes[node._nid]) { delete this.nodes[node._nid]; this.length--; this.firstNode = undefined; } }; // Get the first node from the list, in the document order // Takes O(N) time in the size of the list, with a cache that is invalidated // when the list is modified. MultiId.prototype.getFirst = function() { /* jshint bitwise: false */ if (!this.firstNode) { var nid; for (nid in this.nodes) { if (this.firstNode === undefined || this.firstNode.compareDocumentPosition(this.nodes[nid]) & Node.DOCUMENT_POSITION_PRECEDING) { this.firstNode = this.nodes[nid]; } } } return this.firstNode; }; // If there is only one node left, return it. Otherwise return "this". MultiId.prototype.downgrade = function() { if (this.length === 1) { var nid; for (nid in this.nodes) { return this.nodes[nid]; } } return this; };