Cscott has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/228006

Change subject: Add Tag and Image node support to JsApi; implement get/indexOf.
......................................................................

Add Tag and Image node support to JsApi; implement get/indexOf.

Begin to implement accessor methods on PNodeList, starting with
`get` and `indexOf`.

Add PTag and PImage nodes, and allow mutation of image captions.

Change-Id: I8a8a6899782ecbf0c0d011b5a0c490fa7d3b256d
---
M .jsduck/categories.json
M guides/jsapi/README.md
M lib/jsapi.js
M tests/mocha/jsapi.js
4 files changed, 559 insertions(+), 174 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/services/parsoid 
refs/changes/06/228006/1

diff --git a/.jsduck/categories.json b/.jsduck/categories.json
index f5c45f0..1938360 100644
--- a/.jsduck/categories.json
+++ b/.jsduck/categories.json
@@ -13,6 +13,8 @@
                                        "PExtLink",
                                        "PHeading",
                                        "PHtmlEntity",
+                                       "PImage",
+                                       "PTag",
                                        "PTemplate",
                                        "PText",
                                        "PWikiLink"
diff --git a/guides/jsapi/README.md b/guides/jsapi/README.md
index f3224ef..45620f7 100644
--- a/guides/jsapi/README.md
+++ b/guides/jsapi/README.md
@@ -102,18 +102,18 @@
 ```
 
 Templates can be easily modified to add, remove, or alter params.
-Templates also have a [`matches()`](#!/api/PTemplate-method-matches) method
-for comparing template names, which takes care of capitalization and
+Templates also have a [`nameMatches()`](#!/api/PTemplate-method-nameMatches)
+method for comparing template names, which takes care of capitalization and
 white space:
 
 ```
 > var text = "{{cleanup}} '''Foo''' is a [[bar]]. {{uncategorized}}";
 > var pdoc = yield Parsoid.parse(text, { pdoc: true });
 > pdoc.filterTemplates().forEach(function(template) {
-...    if (template.matches('Cleanup') && !template.has('date')) {
+...    if (template.nameMatches('Cleanup') && !template.has('date')) {
 ...        template.add('date', 'July 2012');
 ...    }
-...    if (template.matches('uncategorized')) {
+...    if (template.nameMatches('uncategorized')) {
 ...        template.name = 'bar-stub';
 ...    }
 ... });
diff --git a/lib/jsapi.js b/lib/jsapi.js
index e91d88d..22a01a5 100644
--- a/lib/jsapi.js
+++ b/lib/jsapi.js
@@ -6,7 +6,7 @@
 require('../lib/core-upgrade.js');
 
 // TO DO:
-// tag/figure
+// extension
 // PTemplate#get should return PParameter and support mutation.
 // PExtLink#url PWikiLink#title should handle mw:ExpandedAttrs
 // make separate package?
@@ -41,7 +41,7 @@
 var noop = function() { };
 
 // Forward declarations of Wrapper classes.
-var PNode, PNodeList, PComment, PExtLink, PHeading, PHtmlEntity, PTemplate, 
PText, PWikiLink;
+var PNode, PNodeList, PComment, PExtLink, PHeading, PHtmlEntity, PImage, PTag, 
PTemplate, PText, PWikiLink;
 
 // HTML escape helper
 var toHtmlStr = function(node, v) {
@@ -84,6 +84,7 @@
        this.parent = parent;
        this.container = container;
        this._update = (opts && opts.update);
+       this._cachedPNodes = null;
 };
 Object.defineProperties(PNodeList.prototype, {
        /**
@@ -105,6 +106,7 @@
         * @method
         */
        update: { value: function() {
+               this._cachedPNodes = null;
                if (this._update) { this._update(); }
                if (this.parent) { this.parent.update(); }
        }, },
@@ -127,7 +129,7 @@
                        if (node.nodeType !== Node.ELEMENT_NODE) {
                                return NodeFilter.FILTER_ACCEPT;
                        }
-                       if 
(/\bmw:Transclusion\b/.test(node.getAttribute('typeof') || '')) {
+                       if (node.matches(PTemplate._selector)) {
                                return NodeFilter.FILTER_ACCEPT;
                        }
                        return NodeFilter.FILTER_SKIP;
@@ -145,7 +147,7 @@
                        // above. But if we ever extend filter() to be fully 
generic,
                        // we might need the commented-out portion of this test.
                        if (node.nodeType === Node.ELEMENT_NODE /* &&
-                               
/\bmw:Transclusion\b/.test(node.getAttribute('typeof') || '')*/
+                               node.matches(PTemplate._selector) */
                        ) {
                                treeWalker.lastChild(); // always skip over all 
children
                                if (!includeTemplates) {
@@ -177,13 +179,13 @@
        _filter: { value: function(result, selector, func, opts) {
                var self = this;
                var recursive = (opts && opts.recursive) !== false;
-               var tSelector = '[typeof~="mw:Transclusion"]';
+               var tSelector = PTemplate._selector;
                if (selector) {
                        tSelector += ',' + selector;
                }
                this._querySelectorAll(tSelector).forEach(function(node) {
                        var isTemplate = node.nodeType === Node.ELEMENT_NODE &&
-                               
/\bmw:Transclusion\b/.test(node.getAttribute('typeof') || '');
+                               node.matches(PTemplate._selector);
                        if (isTemplate) {
                                
self._templatesForNode(node).forEach(function(t) {
                                        if (!selector) {
@@ -214,7 +216,7 @@
         * @return {PComment[]}
         */
        filterComments: { value: function(opts) {
-               return this._filter([], 'COMMENT', function(r, parent, node, 
opts) {
+               return this._filter([], PComment._selector, function(r, parent, 
node, opts) {
                        r.push(new PComment(parent.pdoc, parent, node));
                }, opts);
        }, },
@@ -226,7 +228,7 @@
         * @return {PExtLink[]}
         */
        filterExtLinks: { value: function(opts) {
-               return this._filter([], 'a[rel="mw:ExtLink"]', function(r, 
parent, node, opts) {
+               return this._filter([], PExtLink._selector, function(r, parent, 
node, opts) {
                        r.push(new PExtLink(parent.pdoc, parent, node));
                }, opts);
        }, },
@@ -238,7 +240,7 @@
         * @return {PHeading[]}
         */
        filterHeadings: { value: function(opts) {
-               return this._filter([], 'h1,h2,h3,h4,h5,h6', function(r, 
parent, node, opts) {
+               return this._filter([], PHeading._selector, function(r, parent, 
node, opts) {
                        r.push(new PHeading(parent.pdoc, parent, node));
                }, opts);
        }, },
@@ -250,8 +252,20 @@
         * @return {PHtmlEntity[]}
         */
        filterHtmlEntities: { value: function(opts) {
-               return this._filter([], '[typeof="mw:Entity"]', function(r, 
parent, node, opts) {
+               return this._filter([], PHtmlEntity._selector, function(r, 
parent, node, opts) {
                        r.push(new PHtmlEntity(parent.pdoc, parent, node));
+               }, opts);
+       }, },
+
+       /**
+        * Return an array of {@link PImage} representing images
+        * found in this {@link PNodeList}.
+        * @inheritdoc #_filter
+        * @return {PImage[]}
+        */
+       filterImages: { value: function(opts) {
+               return this._filter([], PImage._selector, function(r, parent, 
node, opts) {
+                       r.push(new PImage(parent.pdoc, parent, node));
                }, opts);
        }, },
 
@@ -272,7 +286,7 @@
         * @return {PText[]}
         */
        filterText: { value: function(opts) {
-               return this._filter([], 'TEXT', function(r, parent, node, opts) 
{
+               return this._filter([], PText._selector, function(r, parent, 
node, opts) {
                        r.push(new PText(parent.pdoc, parent, node));
                }, opts);
        }, },
@@ -284,9 +298,115 @@
         * @return {PWikiLink[]}
         */
        filterWikiLinks: { value: function(opts) {
-               return this._filter([], 'a[rel="mw:WikiLink"]', function(r, 
parent, node, opts) {
+               return this._filter([], PWikiLink._selector, function(r, 
parent, node, opts) {
                        r.push(new PWikiLink(parent.pdoc, parent, node));
                }, opts);
+       }, },
+
+       /**
+        * Internal list of PNodes in this list.
+        * @property {PNode[]}
+        * @private
+        */
+       pnodes: { get: function() {
+               if (this._cachedPNodes !== null) {
+                       return this._cachedPNodes;
+               }
+               var templates = new Set();
+               var result = [];
+               OUTER: for (var i = 0; i < this.container.childNodes.length; 
i++) {
+                       var node = this.container.childNodes.item(i);
+                       if (node.nodeType === Node.TEXT_NODE) {
+                               result.push(new PText(this.pdoc, this, node));
+                               continue;
+                       }
+                       if (node.nodeType === Node.COMMENT_NODE) {
+                               result.push(new PComment(this.pdoc, this, 
node));
+                               continue;
+                       }
+                       if (node.nodeType === Node.ELEMENT_NODE) {
+                               // Note: multiple PTemplates per Node, and 
possibly
+                               // multiple Nodes per PTemplate.
+                               if (node.matches(PTemplate._selector)) {
+                                       
templates.add(node.getAttribute('about'));
+                                       
this._templatesForNode(node).forEach(function(t) {
+                                               result.push(t);
+                                       });
+                                       continue;
+                               } else if 
(templates.has(node.getAttribute('about'))) {
+                                       continue;
+                               }
+                               // PTag is the catch-all; it should always be 
last.
+                               var which = [
+                                       PExtLink, PHeading, PHtmlEntity, 
PImage, PWikiLink,
+                                       PTag
+                               ];
+                               for (var j = 0; j < which.length; j++) {
+                                       var Ty = which[j];
+                                       if (node.matches(Ty._selector)) {
+                                               result.push(new Ty(this.pdoc, 
this, node));
+                                               continue OUTER;
+                                       }
+                               }
+                       }
+                       // Unknown type.
+                       result.push(new PNode(this.pdoc, this, node));
+               }
+               return (this._cachedPNodes = result);
+       }, },
+
+       /**
+        * The number of nodes within the node list.
+        * @property {Number}
+        */
+       length: { get: function() { return this.pnodes.length; }, },
+
+       /**
+        * Return the `index`th node within the node list.
+        * @param {Number} index
+        * @return {PNode}
+        */
+       get: { value: function(index) { return this.pnodes[index]; }, },
+
+       /**
+        * Return the index of `target` in the list of nodes, or `-1` if
+        * the target was not found.
+        *
+        * If `recursive` is true, we will look in all nodes of ours and
+        * their descendants, and return the index of our direct descendant
+        * node which contains the target.  Otherwise, the search is done
+        * only on direct descendants.
+        *
+        * If `fromIndex` is provided, it is the index to start the search
+        * at.
+        * @param {PNode|Node} target
+        * @param {Object} [options]
+        * @param {Boolean} [options.recursive=false]
+        * @param {Number} [options.fromIndex=0]
+        */
+       indexOf: { value: function(target, options) {
+               var recursive = Boolean(options && options.recursive);
+               var fromIndex = Number(options && options.fromIndex) || 0;
+               var child, children;
+               var i, j;
+               if (target instanceof PNode) {
+                       target = target.node;
+               }
+               for (i = fromIndex; i < this.length; i++) {
+                       child = this.get(i);
+                       if (child.matches(target)) {
+                               return i;
+                       }
+                       if (recursive) {
+                               children = child._children();
+                               for (j = 0; j < children.length; j++) {
+                                       if (children[j].indexOf(target, 
options) !== -1) {
+                                               return i;
+                                       }
+                               }
+                       }
+               }
+               return -1;
        }, },
 
        /**
@@ -365,6 +485,12 @@
                set: function(v) { DU.storeDataMw(this.node, v); this.update(); 
},
        },
        /**
+        * Internal helper: enumerate all PNodeLists contained within this node.
+        * @private
+        * @return {PNodeList[]}
+        */
+       _children: { value: function() { return []; }, },
+       /**
         * Call {@link #update} after manually mutating the DOM {@link Node}
         * associated with this {@link PNode} in order to ensure that any
         * containing templates are refreshed with their updated contents.
@@ -376,6 +502,18 @@
        update: { value: function() {
                if (this._update) { this._update(); }
                if (this.parent) { this.parent.update(); }
+       }, },
+       /**
+        * Returns true if the `target` matches this node.  By default a
+        * node matches only if its #node is strictly equal to the target
+        * or the target's #node.  Subclasses can override this to provide
+        * more flexible matching: for example see {@link PText#matches}.
+        * @param {Node|PNode} target
+        * @return {Boolean} true if the target matches this node, false 
otherwise.
+        */
+       matches: { value: function(target) {
+               return (target === this) || (target === this.node) ||
+                       (target instanceof PNode && target.node === this.node);
        }, },
        /**
         * @inheritdoc PNodeList#toHtml
@@ -394,6 +532,321 @@
                return wts(this.pdoc.env, nodes);
        }, },
 });
+
+// Helper: getter and setter for the inner contents of a node.
+var innerAccessor = {
+       get: function() {
+               return new PNodeList(this.pdoc, this, this.node);
+       },
+       set: function(v) {
+               this.node.innerHTML = toHtmlStr(this.node, v);
+               this.update();
+       },
+};
+
+/**
+ * PComment represents a hidden HTML comment, like `<!-- fobar -->`.
+ * @class PComment
+ * @extends PNode
+ */
+/**
+ * @method constructor
+ * @private
+ * @inheritdoc PNode#constructor
+ */
+PComment = function PComment(pdoc, parent, node, opts) {
+       PNode.call(this, pdoc, parent, node, opts);
+};
+util.inherits(PComment, PNode);
+Object.defineProperties(PComment.prototype, {
+       /**
+        * The hidden text contained between `<!--` and `-->`.
+        * @property {String}
+        */
+       contents: {
+               get: function() {
+                       return DU.decodeComment(this.node.data);
+               },
+               set: function(v) {
+                       this.node.data = DU.encodeComment(v);
+                       this.update();
+               },
+       },
+});
+/** @private @static @ignore */
+PComment._selector = 'COMMENT'; // non-standard selector
+
+/**
+ * PExtLink represents an external link, like `[http://example.com Example]`.
+ * @class PExtLink
+ * @extends PNode
+ */
+/**
+ * @method constructor
+ * @private
+ * @inheritdoc PNode#constructor
+ */
+PExtLink = function PExtLink(pdoc, parent, node, opts) {
+       PNode.call(this, pdoc, parent, node, opts);
+};
+util.inherits(PExtLink, PNode);
+Object.defineProperties(PExtLink.prototype, {
+       /**
+        * The URL of the link target.
+        * @property {String}
+        */
+       url: {
+               // XXX url should be a PNodeList, but that requires handling
+               // typeof="mw:ExpandedAttrs"
+               get: function() {
+                       return this.node.getAttribute('href');
+               },
+               set: function(v) {
+                       this.node.setAttribute('href', v);
+               },
+       },
+       /**
+        * The link title, as a {@link PNodeList}.
+        * You can assign a String, Node, or PNodeList to mutate the title.
+        * @property {PNodeList}
+        */
+       title: innerAccessor,
+       // XXX include this.url, once it is a PNodeList
+       _children: { value: function() { return [this.title]; }, },
+});
+/** @private @static @ignore */
+PExtLink._selector = 'a[rel="mw:ExtLink"]';
+
+/**
+ * PHeading represents a section heading in wikitext, like `== Foo ==`.
+ * @class PHeading
+ * @extends PNode
+ */
+/**
+ * @method constructor
+ * @private
+ * @inheritdoc PNode#constructor
+ */
+PHeading = function PHeading(pdoc, parent, node, opts) {
+       PNode.call(this, pdoc, parent, node, opts);
+};
+util.inherits(PHeading, PNode);
+Object.defineProperties(PHeading.prototype, {
+       /**
+        * The heading level, as an integer between 1 and 6 inclusive.
+        * @property {Number}
+        */
+       level: {
+               get: function() {
+                       return +this.node.nodeName.slice(1);
+               },
+               set: function(v) {
+                       v = +v;
+                       if (v === this.level) {
+                               return;
+                       } else if (v >= 1 && v <= 6) {
+                               var nh = this.ownerDocument.createElement('h' + 
v);
+                               while (this.node.firstChild !== null) {
+                                       nh.appendChild(this.node.firstChild);
+                               }
+                               this.node.parentNode.replaceChild(nh, 
this.node);
+                               this.node = nh;
+                               this.update();
+                       } else {
+                               throw new Error("Level must be between 1 and 6, 
inclusive.");
+                       }
+               },
+       },
+       /**
+        * The title of the heading, as a {@link PNodeList}.
+        * You can assign a String, Node, or PNodeList to mutate the title.
+        * @property {PNodeList}
+        */
+       title: innerAccessor,
+
+       _children: { value: function() { return [this.title]; }, },
+});
+/** @private @static @ignore */
+PHeading._selector = 'h1,h2,h3,h4,h5,h6';
+
+/**
+ * PHtmlEntity represents an HTML entity, like `&nbsp;`.
+ * @class PHtmlEntity
+ * @extends PNode
+ */
+/**
+ * @method constructor
+ * @private
+ * @inheritdoc PNode#constructor
+ */
+PHtmlEntity = function PHtmlEntity(pdoc, parent, node, opts) {
+       PNode.call(this, pdoc, parent, node, opts);
+};
+util.inherits(PHtmlEntity, PNode);
+Object.defineProperties(PHtmlEntity.prototype, {
+       /**
+        * The character represented by the HTML entity.
+        * @property {String}
+        */
+       normalized: {
+               get: function() { return this.node.textContent; },
+               set: function(v) {
+                       this.node.textContent = v;
+                       this.node.removeAttribute('data-parsoid');
+                       this.update();
+               },
+       },
+       /**
+        * Extends {@link PNode#matches} to allow a target string to match
+        * if it matches this node's #normalized character.
+        * @method
+        * @inheritdoc PNode#matches
+        * @param {Node|PNode|String} target
+        */
+       matches: { value: function(target) {
+               return PNode.prototype.matches.call(this, target) ||
+                       this.normalized === target;
+       }, },
+});
+/** @private @static @ignore */
+PHtmlEntity._selector = '[typeof="mw:Entity"]';
+
+/**
+ * PImage represents an image in wikitext, like `[[File:Foobar.jpg|caption]]`.
+ * @class PImage
+ * @extends PNode
+ */
+/**
+ * @method constructor
+ * @private
+ * @inheritdoc PNode#constructor
+ */
+PImage = function PImage(pdoc, parent, node, opts) {
+       PNode.call(this, pdoc, parent, node, opts);
+};
+util.inherits(PImage, PNode);
+Object.defineProperties(PImage.prototype, {
+       // Internal helper: is the outer element a <figure> or a <span>?
+       _isBlock: { get: function() { return this.node.tagName === 'FIGURE'; }, 
},
+       // Internal helper: get at the 'caption' property in the dataMw
+       _caption: {
+               get: function() {
+                       var c = this.dataMw.caption;
+                       return c === undefined ? null : c;
+               },
+               set: function(v) {
+                       var dmw = this.dataMw;
+                       if (v===undefined || v===null) {
+                               delete dmw.caption;
+                       } else {
+                               dmw.caption = v;
+                       }
+                       this.dataMw = dmw;
+               },
+       },
+
+       /**
+        * The caption of the image, or `null` if not present.
+        * You can assign `null`, a String, Node, or PNodeList to mutate the
+        * contents.
+        * @property {PNodeList|null}
+        */
+       caption: {
+               get: function() {
+                       var c, captionDiv;
+                       // Note that _cachedNodeList is null if caption is 
missing.
+                       if (this._cachedNodeList === undefined) {
+                               if (this._isBlock) {
+                                       c = this.node.firstChild.nextSibling;
+                                       this._cachedNodeList =
+                                               c ? new PNodeList(this.pdoc, 
this, c) : null;
+                               } else {
+                                       c = this._caption;
+                                       if (c === null) {
+                                               this._cachedNodeList = null;
+                                       } else {
+                                               captionDiv = 
this.ownerDocument.createElement('div');
+                                               captionDiv.innerHTML = c;
+                                               this._cachedNodeList = new 
PNodeList(
+                                                       this.pdoc, this, 
captionDiv, {
+                                                               update: 
function() {
+                                                                       
this.parent._caption = this.container.innerHTML;
+                                                               },
+                                                       });
+                                       }
+                               }
+                       }
+                       return this._cachedNodeList;
+               },
+               set: function(v) {
+                       this._cachedNodeList = undefined;
+                       if (this._isBlock) {
+                               var c = this.node.firstChild.nextSibling;
+                               if (v===null || v===undefined) {
+                                       if (c) {
+                                               this.node.removeChild(c);
+                                               this.update();
+                                       }
+                               } else {
+                                       if (!c) {
+                                               c = 
this.ownerDocument.createElement('figcaption');
+                                               this.node.appendChild(c);
+                                       }
+                                       c.innerHTML = toHtmlStr(c, v);
+                                       this.update();
+                               }
+                       } else {
+                               this._caption = (v===null || v===undefined) ? v 
:
+                                       toHtmlStr(this.node, v);
+                               this.update();
+                       }
+               },
+       },
+
+       _children: { value: function() {
+               var c = this.caption;
+               return c ? [ c ] : [];
+       }, },
+});
+/** @private @static @ignore */
+PImage._selector = 'figure,[typeof~="mw:Image"]';
+
+
+/**
+ * PTag represents any otherwise-unmatched tag.  This includes
+ * HTML-style tags in wikicode, like `<span>`, as well as some
+ * "invisible" tags like `<p>`.
+ * @class PTag
+ * @extends PNode
+ */
+/**
+ * @method constructor
+ * @private
+ * @inheritdoc PNode#constructor
+ */
+PTag = function PTag(pdoc, parent, node, opts) {
+       PNode.call(this, pdoc, parent, node, opts);
+};
+util.inherits(PTag, PNode);
+Object.defineProperties(PTag.prototype, {
+       /**
+        * The name of the tag, in lowercase.
+        */
+       tagName: {
+               get: function() { return this.node.tagName.toLowerCase(); },
+       },
+
+       /**
+        * The contents of the tag, as a {@PNodeList} object.
+        * You can assign a String, Node, or PNodeList to mutate the contents.
+        * @property {PNodeList}
+        */
+       contents: innerAccessor,
+
+       _children: { value: function() { return [this.contents]; }, },
+});
+/** @private @static @ignore */
+PTag._selector = '*'; // any otherwise-unmatched element
 
 /**
  * PTemplate represents a wikitext template, like `{{foo}}`.
@@ -459,7 +912,7 @@
         * @param {String} name The template name to test against.
         * @return {Boolean}
         */
-       matches: {
+       nameMatches: {
                value: function(name) {
                        var href = './' + 
this.pdoc.env.normalizeTitle('Template:' + name);
                        return this._template.template.target.href === href;
@@ -589,162 +1042,19 @@
                        return this._cachedHtml[k];
                },
        },
-});
 
-// Helper: getter and setter for the inner contents of a node.
-var innerAccessor = {
-       get: function() {
-               return new PNodeList(this.pdoc, this, this.node);
-       },
-       set: function(v) {
-               this.node.innerHTML = toHtmlStr(this.node, v);
-               this.update();
-       },
-};
-
-/**
- * PComment represents a hidden HTML comment, like `<!-- fobar -->`.
- * @class PComment
- * @extends PNode
- */
-/**
- * @method constructor
- * @private
- * @inheritdoc PNode#constructor
- */
-PComment = function PComment(pdoc, parent, node, opts) {
-       PNode.call(this, pdoc, parent, node, opts);
-};
-util.inherits(PComment, PNode);
-Object.defineProperties(PComment.prototype, {
-       /**
-        * The hidden text contained between `<!--` and `-->`.
-        * @property {String}
-        */
-       contents: {
-               get: function() {
-                       return DU.decodeComment(this.node.data);
-               },
-               set: function(v) {
-                       this.node.data = DU.encodeComment(v);
-                       this.update();
-               },
-       },
+       _children: { value: function() {
+               var result = [];
+               this.params.forEach(function(k) {
+                       var p = this.get(k);
+                       if (p.key) { result.push(p.key); }
+                       result.push(p.value);
+               }.bind(this));
+               return result;
+       }, },
 });
-
-/**
- * PExtLink represents an external link, like `[http://example.com Example]`.
- * @class PExtLink
- * @extends PNode
- */
-/**
- * @method constructor
- * @private
- * @inheritdoc PNode#constructor
- */
-PExtLink = function PExtLink(pdoc, parent, node, opts) {
-       PNode.call(this, pdoc, parent, node, opts);
-};
-util.inherits(PExtLink, PNode);
-Object.defineProperties(PExtLink.prototype, {
-       /**
-        * The URL of the link target.
-        * @property {String}
-        */
-       url: {
-               // XXX url should be a PNodeList, but that requires handling
-               // typeof="mw:ExpandedAttrs"
-               get: function() {
-                       return this.node.getAttribute('href');
-               },
-               set: function(v) {
-                       this.node.setAttribute('href', v);
-               },
-       },
-       /**
-        * The link title, as a {@link PNodeList}.
-        * You can assign a String, Node, or PNodeList to mutate the title.
-        * @property {PNodeList}
-        */
-       title: innerAccessor,
-});
-
-/**
- * PHeading represents a section heading in wikitext, like `== Foo ==`.
- * @class PHeading
- * @extends PNode
- */
-/**
- * @method constructor
- * @private
- * @inheritdoc PNode#constructor
- */
-PHeading = function PHeading(pdoc, parent, node, opts) {
-       PNode.call(this, pdoc, parent, node, opts);
-};
-util.inherits(PHeading, PNode);
-Object.defineProperties(PHeading.prototype, {
-       /**
-        * The heading level, as an integer between 1 and 6 inclusive.
-        * @property {Number}
-        */
-       level: {
-               get: function() {
-                       return +this.node.nodeName.slice(1);
-               },
-               set: function(v) {
-                       v = +v;
-                       if (v === this.level) {
-                               return;
-                       } else if (v >= 1 && v <= 6) {
-                               var nh = this.ownerDocument.createElement('h' + 
v);
-                               while (this.node.firstChild !== null) {
-                                       nh.appendChild(this.node.firstChild);
-                               }
-                               this.node.parentNode.replaceChild(nh, 
this.node);
-                               this.node = nh;
-                               this.update();
-                       } else {
-                               throw new Error("Level must be between 1 and 6, 
inclusive.");
-                       }
-               },
-       },
-       /**
-        * The title of the heading, as a {@link PNodeList}.
-        * You can assign a String, Node, or PNodeList to mutate the title.
-        * @property {PNodeList}
-        */
-       title: innerAccessor,
-});
-
-/**
- * PHtmlEntity represents an HTML entity, like `&nbsp;`.
- * @class PHtmlEntity
- * @extends PNode
- */
-/**
- * @method constructor
- * @private
- * @inheritdoc PNode#constructor
- */
-PHtmlEntity = function PHtmlEntity(pdoc, parent, node, opts) {
-       PNode.call(this, pdoc, parent, node, opts);
-};
-util.inherits(PHtmlEntity, PNode);
-Object.defineProperties(PHtmlEntity.prototype, {
-       /**
-        * The character represented by the HTML entity.
-        * @property {String}
-        */
-       normalized: {
-               get: function() { return this.node.textContent; },
-               set: function(v) {
-                       this.node.textContent = v;
-                       this.node.removeAttribute('data-parsoid');
-                       this.update();
-               },
-       },
-});
+/** @private @static @ignore */
+PTemplate._selector = '[typeof~="mw:Transclusion"]';
 
 /**
  * PText represents ordinary unformatted text with no special properties.
@@ -774,7 +1084,20 @@
                        this.update();
                },
        },
+       /**
+        * Extends {@link PNode#matches} to allow a target string to match
+        * if it matches this node's #value.
+        * @method
+        * @inheritdoc PNode#matches
+        * @param {Node|PNode|String} target
+        */
+       matches: { value: function(target) {
+               return PNode.prototype.matches.call(this, target) ||
+                       this.value === target;
+       }, },
 });
+/** @private @static @ignore */
+PText._selector = 'TEXT'; // non-standard selector
 
 /**
  * PWikiLink represents an internal wikilink, like `[[Foo|Bar]]`.
@@ -813,7 +1136,11 @@
         * @property {PNodeList}
         */
        text: innerAccessor,
+
+       _children: { value: function() { return [this.text]; }, },
 });
+/** @private @static @ignore */
+PWikiLink._selector = 'a[rel="mw:WikiLink"]';
 
 /**
  * A PDoc object wraps an entire Parsoid document.  Since it is an
@@ -860,4 +1187,14 @@
 module.exports = {
        PDoc: PDoc,
        PNodeList: PNodeList,
+       PNode: PNode,
+       PComment: PComment,
+       PExtLink: PExtLink,
+       PHeading: PHeading,
+       PHtmlEntity: PHtmlEntity,
+       PImage: PImage,
+       PTag: PTag,
+       PTemplate: PTemplate,
+       PText: PText,
+       PWikiLink: PWikiLink,
 };
diff --git a/tests/mocha/jsapi.js b/tests/mocha/jsapi.js
index 0f3b16d..af0cd2d 100644
--- a/tests/mocha/jsapi.js
+++ b/tests/mocha/jsapi.js
@@ -65,10 +65,10 @@
                var text = "{{cleanup}} '''Foo''' is a [[bar]]. 
{{uncategorized}}";
                return Parsoid.parse(text, { pdoc: true }).then(function(pdoc) {
                        pdoc.filterTemplates().forEach(function(template) {
-                               if (template.matches('Cleanup') && 
!template.has('date')) {
+                               if (template.nameMatches('Cleanup') && 
!template.has('date')) {
                                        template.add('date', 'July 2012');
                                }
-                               if (template.matches('uncategorized')) {
+                               if (template.nameMatches('uncategorized')) {
                                        template.name = 'bar-stub';
                                }
                        });
@@ -83,7 +83,7 @@
                var text = "{{echo|{{cleanup}} '''Foo''' is a [[bar]].}} 
{{uncategorized}}";
                return Parsoid.parse(text, { pdoc: true }).then(function(pdoc) {
                        pdoc.filterTemplates().forEach(function(template) {
-                               if (template.matches('Cleanup') && 
!template.has('date')) {
+                               if (template.nameMatches('Cleanup') && 
!template.has('date')) {
                                        template.add('date', 'July 2012');
                                        // Works even when there are special 
characters
                                        template.add('test1', 
'{{foo}}&bar|bat<p>');
@@ -184,6 +184,22 @@
                        String(pdoc).should.equal('<!--<!-- ha! --&gt;--> 
{{echo|<!------>}}');
                });
        });
+       it('filters and mutates images', function() {
+               var text = '[[File:SomeFile1.jpg]] 
[[File:SomeFile2.jpg|thumb|caption]]';
+               return Parsoid.parse(text, { pdoc: true }).then(function(pdoc) {
+                       var images = pdoc.filterImages();
+                       images.length.should.equal(2);
+                       images[0].should.have.property('caption', null);
+                       String(images[1].caption).should.equal('caption');
+                       images[0].caption = '|';
+                       images[1].caption = null;
+                       // XXX Bug T107435
+                       
//String(pdoc).should.equal('[[File:SomeFile1.jpg|<nowiki>|</nowiki>]] 
[[File:SomeFile2.jpg|thumb]]');
+                       images[0].caption = null;
+                       images[1].caption = '|';
+                       String(pdoc).should.equal('[[File:SomeFile1.jpg]] 
[[File:SomeFile2.jpg|thumb|<nowiki>|</nowiki>]]');
+               });
+       });
        it('filters and mutates text', function() {
                var text = 'foo {{echo|bar}}';
                return Parsoid.parse(text, { pdoc: true }).then(function(pdoc) {
@@ -221,4 +237,34 @@
                        });
                });
        });
+       it('allows iteration using length and get()', function() {
+               var text = '== 1 ==\n[http://example.com 2]<!-- 3 
-->&nbsp;{{echo|4}} 5 [[Foo|6]]';
+               return Parsoid.parse(text, { pdoc: true }).then(function(pdoc) {
+                       pdoc.length.should.equal(3);
+                       pdoc.get(0).should.be.instanceof(Parsoid.PHeading);
+                       pdoc.get(1).should.be.instanceof(Parsoid.PText);
+                       pdoc.get(2).should.be.instanceof(Parsoid.PTag);
+                       pdoc.get(2).tagName.should.be.equal('p');
+                       var paragraph = pdoc.get(2).contents;
+                       paragraph.length.should.equal(6);
+                       paragraph.get(0).should.be.instanceof(Parsoid.PExtLink);
+                       paragraph.get(1).should.be.instanceof(Parsoid.PComment);
+                       
paragraph.get(2).should.be.instanceof(Parsoid.PHtmlEntity);
+                       
paragraph.get(3).should.be.instanceof(Parsoid.PTemplate);
+                       paragraph.get(4).should.be.instanceof(Parsoid.PText);
+                       
paragraph.get(5).should.be.instanceof(Parsoid.PWikiLink);
+                       // Test indexOf with PNodes and Nodes
+                       for (var i = 0; i < paragraph.length; i++) {
+                               
paragraph.indexOf(paragraph.get(i)).should.equal(i);
+                               
paragraph.indexOf(paragraph.get(i).node).should.equal(i);
+                               pdoc.indexOf(paragraph.get(i), { recursive: 
true }).should.equal(2);
+                               pdoc.indexOf(paragraph.get(i).node, { 
recursive: true }).should.equal(2);
+                       }
+                       // Test indexOf with strings
+                       pdoc.indexOf(' 5 ').should.equal(-1);
+                       pdoc.indexOf(' 5 ', { recursive: true 
}).should.equal(2);
+                       paragraph.indexOf(' 5 ').should.equal(4);
+                       paragraph.indexOf('\u00A0').should.equal(2);
+               });
+       });
 });

-- 
To view, visit https://gerrit.wikimedia.org/r/228006
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I8a8a6899782ecbf0c0d011b5a0c490fa7d3b256d
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/services/parsoid
Gerrit-Branch: master
Gerrit-Owner: Cscott <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to