Jdlrobson has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/357520 )

Change subject: Migrate Cards code to RelatedArticles
......................................................................

Migrate Cards code to RelatedArticles

* Move across all files
* Rename ext-card- prefix to ext-related-articles- prefix
** Since all code using these prefixes is JS
  we do not have to worry about cached HTML

Bug: T137021
Depends-On: Ib91e491b88380d45bc429c1b408076fc1deba06c
Change-Id: I784fd132c36329fa0dcc49fe2804460061940347
---
M extension.json
M includes/Hooks.php
A includes/ResourceLoaderMuhoganModule.php
A resources/ext.relatedArticles.cards/CardListView.js
A resources/ext.relatedArticles.cards/CardModel.js
A resources/ext.relatedArticles.cards/CardView.js
A resources/ext.relatedArticles.cards/CardsGateway.js
A resources/ext.relatedArticles.cards/card.muhogan
A resources/ext.relatedArticles.cards/cards.muhogan
A resources/ext.relatedArticles.cards/init.js
A resources/ext.relatedArticles.cards/styles.less
A resources/ext.relatedArticles.lib/CSS.escape/LICENSE-MIT.txt
A resources/ext.relatedArticles.lib/CSS.escape/css.escape.js
M resources/ext.relatedArticles.readMore.bootstrap/index.js
M resources/ext.relatedArticles.readMore/index.js
M resources/ext.relatedArticles.readMore/readMore.default.less
A resources/mediawiki.template.muhogan/muhogan.js
M tests/browser/features/support/pages/article_page.rb
M tests/browser/features/support/step_definitions/common_steps.rb
A tests/qunit/ext.relatedArticles.cards/CardModel.js
A tests/qunit/ext.relatedArticles.cards/CardView.js
A tests/qunit/ext.relatedArticles.cards/CardsGateway.js
22 files changed, 832 insertions(+), 6 deletions(-)


  git pull 
ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/RelatedArticles 
refs/changes/20/357520/1

diff --git a/extension.json b/extension.json
index 470e9f4..fdb7471 100644
--- a/extension.json
+++ b/extension.json
@@ -12,6 +12,7 @@
        "license-name": "GPL-2.0",
        "type": "betafeatures",
        "AutoloadClasses": {
+               "RelatedArticles\\ResourceLoaderMuHoganModule": 
"includes/ResourceLoaderMuhoganModule.php",
                "RelatedArticles\\Hooks": "includes/Hooks.php",
                "RelatedArticles\\SidebarHooks": "includes/SidebarHooks.php",
                "RelatedArticles\\FooterHooks": "includes/FooterHooks.php"
@@ -58,6 +59,50 @@
        },
        "manifest_version": 1,
        "ResourceModules": {
+               "mediawiki.template.muhogan": {
+                       "class": "RelatedArticles\\ResourceLoaderMuHoganModule",
+                       "scripts": [
+                               
"resources/mediawiki.template.muhogan/muhogan.js"
+                       ],
+                       "targets": [
+                               "desktop",
+                               "mobile"
+                       ]
+               },
+               "ext.relatedArticles.cards": {
+                       "targets": [
+                               "desktop",
+                               "mobile"
+                       ],
+                       "dependencies": [
+                               "oojs",
+                               "mediawiki.util",
+                               "ext.relatedArticles.lib"
+                       ],
+                       "scripts": [
+                               "resources/ext.relatedArticles.cards/init.js",
+                               
"resources/ext.relatedArticles.cards/CardModel.js",
+                               
"resources/ext.relatedArticles.cards/CardView.js",
+                               
"resources/ext.relatedArticles.cards/CardListView.js",
+                               
"resources/ext.relatedArticles.cards/CardsGateway.js"
+                       ],
+                       "styles": [
+                               
"resources/ext.relatedArticles.cards/styles.less"
+                       ],
+                       "templates": {
+                               "card.muhogan": 
"resources/ext.relatedArticles.cards/card.muhogan",
+                               "cards.muhogan": 
"resources/ext.relatedArticles.cards/cards.muhogan"
+                       }
+               },
+               "ext.relatedArticles.lib": {
+                       "targets": [
+                               "desktop",
+                               "mobile"
+                       ],
+                       "scripts": [
+                               
"resources/ext.relatedArticles.lib/CSS.escape/css.escape.js"
+                       ]
+               },
                "ext.relatedArticles.readMore.gateway": {
                        "scripts": [
                                
"resources/ext.relatedArticles.readMore.gateway/RelatedPagesGateway.js"
diff --git a/includes/Hooks.php b/includes/Hooks.php
index a785ae6..2b86ac9 100644
--- a/includes/Hooks.php
+++ b/includes/Hooks.php
@@ -91,6 +91,17 @@
                        'targets' => [ 'desktop', 'mobile' ],
                ];
 
+               $modules['qunit']['ext.relatedArticles.cards.tests'] = 
$boilerplate + [
+                       'dependencies' => [
+                               'ext.relatedArticles.cards'
+                       ],
+                       'scripts' => [
+                               'ext.relatedArticles.cards/CardModel.js',
+                               'ext.relatedArticles.cards/CardsGateway.js',
+                               'ext.relatedArticles.cards/CardView.js',
+                       ]
+               ];
+
                $modules['qunit']['ext.relatedArticles.readMore.gateway.tests'] 
= $boilerplate + [
                        'scripts' => [
                                
'ext.relatedArticles.readMore.gateway/test_RelatedPagesGateway.js',
diff --git a/includes/ResourceLoaderMuhoganModule.php 
b/includes/ResourceLoaderMuhoganModule.php
new file mode 100644
index 0000000..28258e2
--- /dev/null
+++ b/includes/ResourceLoaderMuhoganModule.php
@@ -0,0 +1,26 @@
+<?php
+namespace RelatedArticles;
+
+use ResourceLoaderFileModule;
+use ResourceLoaderContext;
+
+/**
+ * A ResourceLoader module that serves Hogan or Mustache depending on the
+ * current target.
+ *
+ * FIXME: this is a copy&paste from the QuickSurveys extension. Find a way to
+ * share the code or use mustache in MobileFrontend too.
+ */
+class ResourceLoaderMuHoganModule extends ResourceLoaderFileModule {
+       public function getDependencies( ResourceLoaderContext $context = null 
) {
+               $dependencies = parent::getDependencies( $context );
+
+               if ( $context && $context->getRequest()->getVal( 'target' ) === 
'mobile' ) {
+                       $dependencies[] = 'mediawiki.template.hogan';
+               } else {
+                       $dependencies[] = 'mediawiki.template.mustache';
+               }
+
+               return $dependencies;
+       }
+}
diff --git a/resources/ext.relatedArticles.cards/CardListView.js 
b/resources/ext.relatedArticles.cards/CardListView.js
new file mode 100644
index 0000000..98a8434
--- /dev/null
+++ b/resources/ext.relatedArticles.cards/CardListView.js
@@ -0,0 +1,37 @@
+( function ( $ ) {
+       'use strict';
+
+       /**
+        * View that renders multiple {@link mw.cards.CardView cards}
+        *
+        * @class mw.cards.CardListView
+        * @param {mw.cards.CardView[]} cardViews
+        */
+       function CardListView( cardViews ) {
+               var self = this;
+
+               /**
+                * @property {mw.cards.CardView[]|Array}
+                */
+               this.cardViews = cardViews || [];
+
+               /**
+                * @property {jQuery}
+                */
+               this.$el = $( this.template.render() );
+
+               // We don't want to use template partials because we want to
+               // preserve event handlers of each card view.
+               $.each( this.cardViews, function ( i, cardView ) {
+                       self.$el.append( cardView.$el );
+               } );
+       }
+       OO.initClass( CardListView );
+
+       /**
+        * @property {Object} compiled template
+        */
+       CardListView.prototype.template = mw.template.get( 
'ext.relatedArticles.cards', 'cards.muhogan' );
+
+       mw.cards.CardListView = CardListView;
+} )( jQuery );
diff --git a/resources/ext.relatedArticles.cards/CardModel.js 
b/resources/ext.relatedArticles.cards/CardModel.js
new file mode 100644
index 0000000..9008654
--- /dev/null
+++ b/resources/ext.relatedArticles.cards/CardModel.js
@@ -0,0 +1,56 @@
+( function () {
+       'use strict';
+
+       /**
+        * Model for an article
+        * It is the single source of truth about a Card, which is a 
representation
+        * of a wiki article. It emits a 'change' event when its attribute 
changes.
+        * A View can listen to this event and update the UI accordingly.
+        *
+        * @class mw.cards.CardModel
+        * @extends OO.EventEmitter
+        * @param {Object} attributes article data, such as title, url, etc. 
about
+        *  an article
+        */
+       function CardModel( attributes ) {
+               CardModel.super.apply( this, arguments );
+               /**
+                * @property {Object} attributes of the model
+                */
+               this.attributes = attributes;
+       }
+       OO.inheritClass( CardModel, OO.EventEmitter );
+
+       /**
+        * Set a model attribute.
+        * Emits a 'change' event with the object whose key is the attribute
+        * that's being updated and value is the value that's being set. The 
event
+        * can also be silenced.
+        *
+        * @param {String} key attribute that's being set
+        * @param {Mixed} value the value of the key param
+        * @param {Boolean} [silent] whether to emit the 'change' event. By 
default
+        *  the 'change' event will be emitted.
+        */
+       CardModel.prototype.set = function ( key, value, silent ) {
+               var event = {};
+
+               this.attributes[ key ] = value;
+               if ( !silent ) {
+                       event[ key ] = value;
+                       this.emit( 'change', event );
+               }
+       };
+
+       /**
+        * Get the model attribute's value.
+        *
+        * @param key attribute that's being looked up
+        * @returns {Mixed}
+        */
+       CardModel.prototype.get = function ( key ) {
+               return this.attributes[ key ];
+       };
+
+       mw.cards.CardModel = CardModel;
+} )();
diff --git a/resources/ext.relatedArticles.cards/CardView.js 
b/resources/ext.relatedArticles.cards/CardView.js
new file mode 100644
index 0000000..50f3bd7
--- /dev/null
+++ b/resources/ext.relatedArticles.cards/CardView.js
@@ -0,0 +1,52 @@
+( function ( $ ) {
+       'use strict';
+
+       /**
+        * Renders a Card model and updates when it does.
+        *
+        * @class mw.cards.CardView
+        * @param {mw.cards.CardModel} model
+        */
+       function CardView( model ) {
+               /**
+                * @property {mw.cards.CardModel}
+                */
+               this.model = model;
+
+               // listen to model changes and re-render the view
+               this.model.on( 'change', this.render.bind( this ) );
+
+               /**
+                * @property {jQuery}
+                */
+               this.$el = $( this._render() );
+       }
+       OO.initClass( CardView );
+
+       /**
+        * @property {Object} compiled template
+        */
+       CardView.prototype.template = mw.template.get( 
'ext.relatedArticles.cards', 'card.muhogan' );
+
+       /**
+        * Replace the html of this.$el with a newly rendered html using the 
model
+        * attributes.
+        */
+       CardView.prototype.render = function () {
+               this.$el.replaceWith( this._render() );
+       };
+
+       /**
+        * Renders the template using the model attributes.
+        *
+        * @ignore
+        */
+       CardView.prototype._render = function () {
+               var attributes = $.extend( {}, this.model.attributes );
+               attributes.thumbnailUrl = CSS.escape( attributes.thumbnailUrl );
+
+               return this.template.render( attributes );
+       };
+
+       mw.cards.CardView = CardView;
+} )( jQuery );
diff --git a/resources/ext.relatedArticles.cards/CardsGateway.js 
b/resources/ext.relatedArticles.cards/CardsGateway.js
new file mode 100644
index 0000000..236d03c
--- /dev/null
+++ b/resources/ext.relatedArticles.cards/CardsGateway.js
@@ -0,0 +1,103 @@
+( function ( $ ) {
+       'use strict';
+
+       /**
+        * Default thumbnail width in pixels: 80px
+        * @readonly
+        */
+       var THUMB_WIDTH = 80,
+               CardModel = mw.cards.CardModel,
+               CardView = mw.cards.CardView,
+               CardListView = mw.cards.CardListView;
+
+       /**
+        * Gateway for interacting with an API
+        * It can be used to retrieve information about article(s). In the 
future
+        * it can also be used to update that information in the server.
+        *
+        * @class mw.cards.CardsGateway
+        * @param {Object} options
+        * @param {mw.Api} options.api an Api to use.
+        */
+       function CardsGateway( options ) {
+               this.api = options.api;
+       }
+       OO.initClass( CardsGateway );
+
+       /**
+        * Fetch information about articleTitles from the API
+        * How to use:
+        *
+        *     @example
+        *     var gateway = new mw.cards.CardsGateway( { api: new mw.Api() } );
+        *
+        *     // '1' and '2' are page titles, while 200 is the desired 
thumbnail width
+        *     gateway.getCards( ['1', '2'], 200 ).done( function( cards ) {
+        *         $( '#bodyContent' ).append( cards.$el );
+        *     } );
+        *
+        * @param {String[]} articleTitles array of article titles
+        * @param {Number} [thumbWidth] Thumbnail width in pixels. Defaults to
+        *  {@link THUMB_WIDTH}
+        * @return {jQuery.Deferred} the result resolves with a
+        *  {@link mw.cards.CardListView card list}
+        */
+       CardsGateway.prototype.getCards = function ( articleTitles, thumbWidth 
) {
+               var article,
+                       cardViews = [],
+                       result = $.Deferred();
+
+               if ( !articleTitles.length ) {
+                       result.resolve( new CardListView( cardViews ) );
+                       return result;
+               }
+
+               this.api.get( {
+                       action: 'query',
+                       prop: 'extracts|pageimages',
+                       explaintext: true,
+                       exlimit: articleTitles.length,
+                       exintro: true,
+                       exsentences: 1,
+                       pithumbsize: thumbWidth || THUMB_WIDTH,
+                       titles: articleTitles.join( '|' ),
+                       continue: '',
+                       formatversion: 2
+               } ).done( function ( data ) {
+                       if ( data.query && data.query.pages ) {
+                               cardViews = $.map( data.query.pages, function ( 
page ) {
+                                       article = {
+                                               title: page.title,
+                                               url: mw.util.getUrl( page.title 
),
+                                               hasThumbnail: false
+                                       };
+
+                                       if ( page.thumbnail && 
isValidThumbnail( page.thumbnail ) ) {
+                                               article.hasThumbnail = true;
+                                               article.thumbnailUrl = 
page.thumbnail.source;
+                                       }
+
+                                       if ( page.extract ) {
+                                               article.extract = page.extract;
+                                       }
+
+                                       return new CardView( new CardModel( 
article ) );
+                               } );
+                       }
+                       result.resolve( new CardListView( cardViews ) );
+               } ).fail( function () {
+                       result.resolve( new CardListView( cardViews ) );
+               } );
+
+               return result;
+       };
+
+       /**
+        * @ignore
+        */
+       function isValidThumbnail( thumb ) {
+               return thumb.source.substr( 0, 7 ) === 'http://' || 
thumb.source.substr( 0, 8 ) === 'https://';
+       }
+
+       mw.cards.CardsGateway = CardsGateway;
+} )( jQuery );
diff --git a/resources/ext.relatedArticles.cards/card.muhogan 
b/resources/ext.relatedArticles.cards/card.muhogan
new file mode 100644
index 0000000..adbabb3
--- /dev/null
+++ b/resources/ext.relatedArticles.cards/card.muhogan
@@ -0,0 +1,8 @@
+<li title="{{ title }}" class="ext-related-articles-card">
+       <div class="ext-related-articles-card-thumb" {{# hasThumbnail 
}}style="background-image: url( '{{ thumbnailUrl }}' );"{{/ hasThumbnail 
}}></div>
+       <a href="{{ url }}" aria-hidden="true" tabindex="-1"></a>
+       <div class="ext-related-articles-card-detail">
+               <h3><a href="{{ url }}">{{ title }}</a></h3>
+               {{# extract }}<p class="ext-related-articles-card-extract">{{ 
extract }}</p>{{/ extract }}
+       </div>
+</li>
diff --git a/resources/ext.relatedArticles.cards/cards.muhogan 
b/resources/ext.relatedArticles.cards/cards.muhogan
new file mode 100644
index 0000000..b68a326
--- /dev/null
+++ b/resources/ext.relatedArticles.cards/cards.muhogan
@@ -0,0 +1,2 @@
+<ul class="ext-related-articles-card-list">
+</ul>
diff --git a/resources/ext.relatedArticles.cards/init.js 
b/resources/ext.relatedArticles.cards/init.js
new file mode 100644
index 0000000..e706610
--- /dev/null
+++ b/resources/ext.relatedArticles.cards/init.js
@@ -0,0 +1,12 @@
+( function () {
+       'use strict';
+
+       /**
+        * @class mw.cards
+        * @singleton
+        */
+       mw.cards = {
+               models: {},
+               views: {}
+       };
+} )();
diff --git a/resources/ext.relatedArticles.cards/styles.less 
b/resources/ext.relatedArticles.cards/styles.less
new file mode 100644
index 0000000..2a09ea5
--- /dev/null
+++ b/resources/ext.relatedArticles.cards/styles.less
@@ -0,0 +1,151 @@
+@import 'mediawiki.mixins';
+@import 'mediawiki.ui/variables';
+
+@baseFontSize: 1em;
+@thumbWidth: 80px;
+@cardBorder: 1px solid rgba( 0, 0, 0, 0.2 );
+@borderRadius: 2px;
+
+.ext-related-articles-card-list {
+       .flex-display();
+       flex-flow: row wrap;
+       justify-content: flex-start;
+       font-size: @baseFontSize;
+       list-style: none;
+       overflow: hidden;
+       position: relative;
+
+       .ext-related-articles-card {
+               background-color: #fff;
+               box-sizing: border-box;
+               flex: 1 0 auto;
+               margin: 0;
+               height: @thumbWidth;
+               position: relative;
+               width: 100%;
+               border: @cardBorder;
+               & + .ext-related-articles-card {
+                       border-top: 0;
+               }
+               // Apply radius to top & bottom cards when stacked
+               &:first-child {
+                       border-radius: @borderRadius @borderRadius 0 0;
+               }
+               &:last-child {
+                       border-radius: 0 0 @borderRadius @borderRadius;
+               }
+       }
+
+       .ext-related-articles-card > a {
+               position: absolute;
+               top: 0;
+               right: 0;
+               bottom: 0;
+               left: 0;
+               z-index: 1;
+
+               &:hover {
+                       box-shadow: 0 1px 1px rgba( 0, 0, 0, 0.1 );
+               }
+       }
+
+       h3 {
+               @fontSize: 1em;
+               @lineHeight: 1.3;
+               @lineHeightEm: @lineHeight * @fontSize;
+
+               font-family: inherit;
+               font-size: @fontSize;
+               // max 2 lines
+               max-height: 2 * @lineHeightEm;
+               line-height: @lineHeight;
+               margin: 0;
+               overflow: hidden;
+               padding: 0;
+               position: relative;
+               font-weight: 500;
+
+               a {
+                       color: #000;
+               }
+
+               &:after {
+                       content: ' ';
+                       position: absolute;
+                       right: 0;
+                       bottom: 0;
+                       width: 25%;
+                       height: @lineHeightEm;
+                       background-color: transparent;
+                       background-image: -webkit-linear-gradient( right, rgba( 
255, 255, 255, 0 ), rgba( 255, 255, 255, 1 ) 50% );
+                       background-image: -moz-linear-gradient( right, rgba( 
255, 255, 255, 0 ), rgba( 255, 255, 255, 1 ) 50% );
+                       background-image: -o-linear-gradient( right, rgba( 255, 
255, 255, 0 ), rgba( 255, 255, 255, 1 ) 50% );
+                       background-image: linear-gradient( to right, rgba( 255, 
255, 255, 0 ), rgba( 255, 255, 255, 1 ) 50% );
+               }
+       }
+
+       .ext-related-articles-card-detail {
+               // Vertically center the element using the technique described 
at
+               // 
http://zerosixthree.se/vertical-align-anything-with-just-3-lines-of-css/.
+               //
+               // This technique is ideal because:
+               // * it's easy to reason about,
+               // * `position: relative` means that the element is laid out as 
if it weren't
+               //   positioned, allowing for `text-overflow: ellipsis` to work 
(see below)
+               // * it supports more browsers than flexbox does, and
+               // * we don't deliver RelatedArticles/Cards assets to those 
browsers that don't
+               //   support CSS 2D transforms
+               position: relative;
+               top: 50%;
+               -webkit-transform: translateY( -50% ); // iOS 8.1, Android 
Browser 4.3-4.4.4
+               -ms-transform: translateY( -50% ); // IE9
+               transform: translateY( -50% );
+       }
+
+       .ext-related-articles-card-extract {
+               color: @colorGray8;
+               font-size: 0.8em;
+               white-space: nowrap;
+               overflow: hidden;
+               text-overflow: ellipsis;
+               margin-top: 2px;
+       }
+
+       .ext-related-articles-card-thumb {
+               background-color: @colorGray14;
+               .background-image-svg( 'noimage.svg', 'noimage.png' );
+               background-repeat: no-repeat;
+               background-position: top center;
+               .background-size( 100%, 100% );
+               background-size: cover;
+               float: left;
+               height: 100%;
+               width: @thumbWidth;
+               margin-right: 10px;
+       }
+}
+
+@media all and ( min-width: @deviceWidthTablet ) {
+       .ext-related-articles-card-list {
+               border-top: 0;
+               .ext-related-articles-card {
+                       border: @cardBorder;
+                       margin-right: 10px;
+                       width: 30%;
+
+                       // Individual border-radius when cards side by side 
(not stacked)
+                       &,
+                       &:first-child,
+                       &:last-child {
+                               border-radius: @borderRadius;
+                       }
+
+                       &:last-child {
+                               margin-right: 0;
+                       }
+                       & + .ext-related-articles-card {
+                               border: @cardBorder;
+                       }
+               }
+       }
+}
diff --git a/resources/ext.relatedArticles.lib/CSS.escape/LICENSE-MIT.txt 
b/resources/ext.relatedArticles.lib/CSS.escape/LICENSE-MIT.txt
new file mode 100644
index 0000000..a41e0a7
--- /dev/null
+++ b/resources/ext.relatedArticles.lib/CSS.escape/LICENSE-MIT.txt
@@ -0,0 +1,20 @@
+Copyright Mathias Bynens <https://mathiasbynens.be/>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/resources/ext.relatedArticles.lib/CSS.escape/css.escape.js 
b/resources/ext.relatedArticles.lib/CSS.escape/css.escape.js
new file mode 100644
index 0000000..e35cd3a
--- /dev/null
+++ b/resources/ext.relatedArticles.lib/CSS.escape/css.escape.js
@@ -0,0 +1,95 @@
+/*! https://mths.be/cssescape v1.1.0 by @mathias | MIT license */
+;(function(root) {
+
+       if (!root.CSS) {
+               root.CSS = {};
+       }
+
+       var CSS = root.CSS;
+
+       var InvalidCharacterError = function(message) {
+               this.message = message;
+       };
+       InvalidCharacterError.prototype = new Error;
+       InvalidCharacterError.prototype.name = 'InvalidCharacterError';
+
+       if (!CSS.escape) {
+               // https://drafts.csswg.org/cssom/#serialize-an-identifier
+               CSS.escape = function(value) {
+                       var string = String(value);
+                       var length = string.length;
+                       var index = -1;
+                       var codeUnit;
+                       var result = '';
+                       var firstCodeUnit = string.charCodeAt(0);
+                       while (++index < length) {
+                               codeUnit = string.charCodeAt(index);
+                               // Note: there’s no need to special-case astral 
symbols, surrogate
+                               // pairs, or lone surrogates.
+
+                               // If the character is NULL (U+0000), then 
throw an
+                               // `InvalidCharacterError` exception and 
terminate these steps.
+                               if (codeUnit == 0x0000) {
+                                       throw new InvalidCharacterError(
+                                               'Invalid character: the input 
contains U+0000.'
+                                       );
+                               }
+
+                               if (
+                                       // If the character is in the range 
[\1-\1F] (U+0001 to U+001F) or is
+                                       // U+007F, […]
+                                       (codeUnit >= 0x0001 && codeUnit <= 
0x001F) || codeUnit == 0x007F ||
+                                       // If the character is the first 
character and is in the range [0-9]
+                                       // (U+0030 to U+0039), […]
+                                       (index == 0 && codeUnit >= 0x0030 && 
codeUnit <= 0x0039) ||
+                                       // If the character is the second 
character and is in the range [0-9]
+                                       // (U+0030 to U+0039) and the first 
character is a `-` (U+002D), […]
+                                       (
+                                               index == 1 &&
+                                               codeUnit >= 0x0030 && codeUnit 
<= 0x0039 &&
+                                               firstCodeUnit == 0x002D
+                                       )
+                               ) {
+                                       // 
https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
+                                       result += '\\' + codeUnit.toString(16) 
+ ' ';
+                                       continue;
+                               }
+
+                               if (
+                                       // If the character is the first 
character and is a `-` (U+002D), and
+                                       // there is no second character, […]
+                                       index == 0 &&
+                                       length == 1 &&
+                                       codeUnit == 0x002D
+                               ) {
+                                       result += '\\' + string.charAt(index);
+                                       continue;
+                               }
+
+                               // If the character is not handled by one of 
the above rules and is
+                               // greater than or equal to U+0080, is `-` 
(U+002D) or `_` (U+005F), or
+                               // is in one of the ranges [0-9] (U+0030 to 
U+0039), [A-Z] (U+0041 to
+                               // U+005A), or [a-z] (U+0061 to U+007A), […]
+                               if (
+                                       codeUnit >= 0x0080 ||
+                                       codeUnit == 0x002D ||
+                                       codeUnit == 0x005F ||
+                                       codeUnit >= 0x0030 && codeUnit <= 
0x0039 ||
+                                       codeUnit >= 0x0041 && codeUnit <= 
0x005A ||
+                                       codeUnit >= 0x0061 && codeUnit <= 0x007A
+                               ) {
+                                       // the character itself
+                                       result += string.charAt(index);
+                                       continue;
+                               }
+
+                               // Otherwise, the escaped character.
+                               // 
https://drafts.csswg.org/cssom/#escape-a-character
+                               result += '\\' + string.charAt(index);
+
+                       }
+                       return result;
+               };
+       }
+
+}(typeof global != 'undefined' ? global : this));
diff --git a/resources/ext.relatedArticles.readMore.bootstrap/index.js 
b/resources/ext.relatedArticles.readMore.bootstrap/index.js
index 2dfb200..953a811 100644
--- a/resources/ext.relatedArticles.readMore.bootstrap/index.js
+++ b/resources/ext.relatedArticles.readMore.bootstrap/index.js
@@ -55,7 +55,7 @@
                                // to avoid PHP exceptions when Cards not 
installed
                                // which should never happen given the if 
statement.
                                mw.loader.using( [
-                                       'ext.cards',
+                                       'ext.relatedArticles.cards',
                                        'ext.relatedArticles.readMore'
                                ] ),
                                relatedPages.getForCurrentPage( LIMIT )
diff --git a/resources/ext.relatedArticles.readMore/index.js 
b/resources/ext.relatedArticles.readMore/index.js
index a72f90c..9a42c3c 100644
--- a/resources/ext.relatedArticles.readMore/index.js
+++ b/resources/ext.relatedArticles.readMore/index.js
@@ -1,7 +1,7 @@
 ( function ( $, mw ) {
-       // Make sure 'ext.cards' is loaded. It may not be because of the race
+       // Make sure 'ext.relatedArticles.cards' is loaded. It may not be 
because of the race
        // condition in the bootstrap file.
-       mw.loader.using( 'ext.cards' ).done( function () {
+       mw.loader.using( 'ext.relatedArticles.cards' ).done( function () {
                var CardModel = mw.cards.CardModel,
                        CardView = mw.cards.CardView,
                        CardListView = mw.cards.CardListView;
diff --git a/resources/ext.relatedArticles.readMore/readMore.default.less 
b/resources/ext.relatedArticles.readMore/readMore.default.less
index 2ad1342..1d0cb7f 100644
--- a/resources/ext.relatedArticles.readMore/readMore.default.less
+++ b/resources/ext.relatedArticles.readMore/readMore.default.less
@@ -12,7 +12,7 @@
 .ra-read-more {
        padding: 1em;
 
-       .ext-cards-card-list {
+       .ext-related-articles-card-list {
                margin-left: 0;
        }
 }
diff --git a/resources/mediawiki.template.muhogan/muhogan.js 
b/resources/mediawiki.template.muhogan/muhogan.js
new file mode 100644
index 0000000..7379d6e
--- /dev/null
+++ b/resources/mediawiki.template.muhogan/muhogan.js
@@ -0,0 +1,21 @@
+/*jshint -W002 */
+// Register the Hogan compiler with MediaWiki.
+( function () {
+       var compiler;
+       /*
+        * Check if muhogan is already registered (by QuickSurveys). If not
+        * register mustache (Desktop) or hogan (Mobile) as muhogan.
+        */
+       try {
+               mw.template.getCompiler( 'muhogan' );
+       } catch ( e ) {
+               try {
+                       compiler = mw.template.getCompiler( 'mustache' );
+               } catch ( e ) {
+                       compiler = mw.template.getCompiler( 'hogan' );
+               }
+
+               // register hybrid compiler with core
+               mw.template.registerCompiler( 'muhogan', compiler );
+       }
+}() );
diff --git a/tests/browser/features/support/pages/article_page.rb 
b/tests/browser/features/support/pages/article_page.rb
index d1d547c..7a2992c 100644
--- a/tests/browser/features/support/pages/article_page.rb
+++ b/tests/browser/features/support/pages/article_page.rb
@@ -7,5 +7,5 @@
            '<%= params[:hash] %>'
 
   aside(:read_more, css: '.ra-read-more')
-  li(:read_more_cards, css: '.ext-cards-card')
+  li(:read_more_cards, css: '.ext-related-articles-card')
 end
diff --git a/tests/browser/features/support/step_definitions/common_steps.rb 
b/tests/browser/features/support/step_definitions/common_steps.rb
index 0af61a1..a4d1158 100644
--- a/tests/browser/features/support/step_definitions/common_steps.rb
+++ b/tests/browser/features/support/step_definitions/common_steps.rb
@@ -75,5 +75,5 @@
 end
 
 Then(/^ReadMore must have three cards$/) do
-  expect(browser.execute_script("return $('.ext-cards-card').length")).to eq(3)
+  expect(browser.execute_script("return 
$('.ext-related-articles-card').length")).to eq(3)
 end
diff --git a/tests/qunit/ext.relatedArticles.cards/CardModel.js 
b/tests/qunit/ext.relatedArticles.cards/CardModel.js
new file mode 100644
index 0000000..ec78673
--- /dev/null
+++ b/tests/qunit/ext.relatedArticles.cards/CardModel.js
@@ -0,0 +1,35 @@
+( function () {
+       'use strict';
+
+       var CardModel = mw.cards.CardModel;
+
+       QUnit.module( 'ext.relatedArticles.cards/CardModel' );
+
+       QUnit.test( '#set', 1, function ( assert ) {
+               var model = new CardModel( {} );
+
+               model.on( 'change', function ( attributes ) {
+                       assert.strictEqual(
+                               attributes.foo,
+                               'bar',
+                               'It emits an event with the attribute that has 
changed.'
+                       );
+               } );
+               model.set( 'foo', 'bar' );
+
+               model = new CardModel( {} );
+               model.on( 'change', function () {
+                       assert.ok( false, 'It doesn\'t emit an event when 
silenced.' );
+               } );
+
+               model.set( 'foo', 'bar', true );
+       } );
+
+       QUnit.test( '#get', 2, function ( assert ) {
+               var model = new CardModel( {} );
+
+               model.set( 'foo', 'bar' );
+               assert.strictEqual( model.get( 'foo' ), 'bar', 'Got the correct 
value.' );
+               assert.strictEqual( model.get( 'x' ), undefined, 'Got the 
correct value.' );
+       } );
+}() );
\ No newline at end of file
diff --git a/tests/qunit/ext.relatedArticles.cards/CardView.js 
b/tests/qunit/ext.relatedArticles.cards/CardView.js
new file mode 100644
index 0000000..e281834
--- /dev/null
+++ b/tests/qunit/ext.relatedArticles.cards/CardView.js
@@ -0,0 +1,29 @@
+( function ( $ ) {
+       'use strict';
+
+       var CardModel = mw.cards.CardModel,
+               CardView = mw.cards.CardView;
+
+       QUnit.module( 'ext.relatedArticles.cards/CardView' );
+
+       QUnit.test( '#_render escapes the thumbnailUrl model attribute', 1, 
function ( assert ) {
+               var model = new CardModel( {
+                               title: 'One',
+                               url: mw.util.getUrl( 'One' ),
+                               hasThumbnail: true,
+                               thumbnailUrl: 
'http://foo.bar/\');display:none;"//baz.jpg',
+                               isThumbnailProtrait: false,
+                       } ),
+                       view = new CardView( model ),
+                       style;
+
+               style = view.$el.find( '.ext-related-articles-card-thumb' )
+                       .eq( 0 )
+                       .attr( 'style' );
+
+               assert.equal(
+                       style,
+                       'background-image: url( 
\'http\\:\\/\\/foo\\.bar\\/\\\'\\)\\;display\\:none\\;\\"\\/\\/baz\\.jpg\' );'
+               );
+       } );
+}( jQuery ) );
diff --git a/tests/qunit/ext.relatedArticles.cards/CardsGateway.js 
b/tests/qunit/ext.relatedArticles.cards/CardsGateway.js
new file mode 100644
index 0000000..1286c54
--- /dev/null
+++ b/tests/qunit/ext.relatedArticles.cards/CardsGateway.js
@@ -0,0 +1,123 @@
+( function ( $ ) {
+
+       var CardsGateway = mw.cards.CardsGateway;
+
+       QUnit.module( 'ext.relatedArticles.cards/CardsGateway' );
+
+       QUnit.test( '#getCards resolves with empty list of cards when no titles 
are given', 1, function ( assert ) {
+               var cards = new CardsGateway( { api: new mw.Api() } );
+
+               return cards.getCards( [] ).then( function ( cards ) {
+                       assert.ok( cards.cardViews.length === 0 );
+               } );
+       } );
+
+       QUnit.test( '#getCards processes the result of the API call', 5, 
function ( assert ) {
+               var api = new mw.Api(),
+                       cards = new CardsGateway( { api: api } ),
+                       result = {
+                               query: {
+                                       pages: [
+                                               {
+                                                       pageid: 1,
+                                                       ns: 0,
+                                                       title: 'One'
+                                               },
+                                               {
+                                                       pageid: 2,
+                                                       ns: 0,
+                                                       title: 'Two',
+                                                       extract: 'This is the 
second page.'
+                                               },
+                                               {
+                                                       pageid: 3,
+                                                       ns: 0,
+                                                       title: 'Three',
+                                                       thumbnail: {
+                                                               source: 
'http://foo.bar/baz.jpg',
+                                                               width: 50,
+                                                               height: 38
+                                                       },
+                                                       pageimage: 'baz.jpg'
+                                               },
+
+                                               // [T118553] Thumbnail URLs 
must start with
+                                               // "http[s]://".
+                                               {
+                                                       pageid: 4,
+                                                       ns: 0,
+                                                       title: 'Four',
+                                                       thumbnail: {
+                                                               source: 
'//foo.bar/baz/qux.jpg',
+                                                               width: 50,
+                                                               height: 38
+                                                       },
+                                                       pageimage: 'qux.jpg'
+                                               }
+                                       ]
+                               }
+                       };
+
+               this.sandbox.stub( api, 'get' ).returns( $.Deferred().resolve( 
result ) );
+
+               return cards.getCards( [ 'One', 'Two', 'Three', 'Four' ] 
).then( function ( cards ) {
+                       assert.ok( cards.cardViews.length === 4 );
+
+                       // One: no extract; no thumbnail.
+                       assert.deepEqual( cards.cardViews[0].model.attributes, {
+                               title: 'One',
+                               url: mw.util.getUrl( 'One' ),
+                               hasThumbnail: false
+                       } );
+
+                       // Two: no thumbnail.
+                       assert.deepEqual( cards.cardViews[1].model.attributes, {
+                               title: 'Two',
+                               url: mw.util.getUrl( 'Two' ),
+                               hasThumbnail: false,
+                               extract: 'This is the second page.'
+                       } );
+
+                       // Three: no extract.
+                       assert.deepEqual( cards.cardViews[2].model.attributes, {
+                               title: 'Three',
+                               url: mw.util.getUrl( 'Three' ),
+                               hasThumbnail: true,
+                               thumbnailUrl: 'http://foo.bar/baz.jpg'
+                       } );
+
+                       // Four: invalid thumbnail URL.
+                       assert.deepEqual( cards.cardViews[3].model.attributes, {
+                               title: 'Four',
+                               url: mw.util.getUrl( 'Four' ),
+                               hasThumbnail: false
+                       } );
+               } );
+       } );
+
+       QUnit.test( '#getCards resolves with empty list of cards when the API 
call fails', 2, function ( assert ) {
+               var api = new mw.Api(),
+                       cards = new CardsGateway( { api: api } ),
+                       getStub = this.sandbox.stub( api, 'get' ),
+                       done1 = assert.async(),
+                       done2 = assert.async();
+
+               getStub.returns( $.Deferred().reject() );
+
+               cards.getCards( [ 'Foo' ] ).then( function ( cards ) {
+                       assert.ok( cards.cardViews.length === 0 );
+               } )
+               .always( done1 );
+
+               // The API call can succeed but return no results, which should
+               // also be handled as a failure.
+               getStub.returns( $.Deferred().resolve( {
+                       query: {}
+               } ) );
+
+               cards.getCards( [ 'Foo' ] ).then( function ( cards ) {
+                       assert.ok( cards.cardViews.length === 0 );
+               } )
+               .always( done2 );
+       } );
+}( jQuery ) );

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

Gerrit-MessageType: newchange
Gerrit-Change-Id: I784fd132c36329fa0dcc49fe2804460061940347
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/RelatedArticles
Gerrit-Branch: master
Gerrit-Owner: Jdlrobson <[email protected]>

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

Reply via email to