Merge "CommentStore: Hard-deprecate newKey()"
[lhc/web/wiklou.git] / resources / src / mediawiki.base / mediawiki.base.js
1 /*!
2 * This file is currently loaded as part of the 'mediawiki' module and therefore
3 * concatenated to mediawiki.js and executed at the same time. This file exists
4 * to help prepare for splitting up the 'mediawiki' module.
5 * This effort is tracked at https://phabricator.wikimedia.org/T192623
6 *
7 * In short:
8 *
9 * - mediawiki.js will be reduced to the minimum needed to define mw.loader and
10 * mw.config, and then moved to its own private "mediawiki.loader" module that
11 * can be embedded within the StartupModule response.
12 *
13 * - mediawiki.base.js and other files in this directory will remain part of the
14 * "mediawiki" module, and will remain a default/implicit dependency for all
15 * regular modules, just like jquery and wikibits already are.
16 */
17 ( function () {
18 'use strict';
19
20 var slice = Array.prototype.slice,
21 mwLoaderTrack = mw.track,
22 trackCallbacks = $.Callbacks( 'memory' ),
23 trackHandlers = [],
24 queue;
25
26 /**
27 * Object constructor for messages.
28 *
29 * Similar to the Message class in MediaWiki PHP.
30 *
31 * Format defaults to 'text'.
32 *
33 * @example
34 *
35 * var obj, str;
36 * mw.messages.set( {
37 * 'hello': 'Hello world',
38 * 'hello-user': 'Hello, $1!',
39 * 'welcome-user': 'Welcome back to $2, $1! Last visit by $1: $3'
40 * } );
41 *
42 * obj = new mw.Message( mw.messages, 'hello' );
43 * mw.log( obj.text() );
44 * // Hello world
45 *
46 * obj = new mw.Message( mw.messages, 'hello-user', [ 'John Doe' ] );
47 * mw.log( obj.text() );
48 * // Hello, John Doe!
49 *
50 * obj = new mw.Message( mw.messages, 'welcome-user', [ 'John Doe', 'Wikipedia', '2 hours ago' ] );
51 * mw.log( obj.text() );
52 * // Welcome back to Wikipedia, John Doe! Last visit by John Doe: 2 hours ago
53 *
54 * // Using mw.message shortcut
55 * obj = mw.message( 'hello-user', 'John Doe' );
56 * mw.log( obj.text() );
57 * // Hello, John Doe!
58 *
59 * // Using mw.msg shortcut
60 * str = mw.msg( 'hello-user', 'John Doe' );
61 * mw.log( str );
62 * // Hello, John Doe!
63 *
64 * // Different formats
65 * obj = new mw.Message( mw.messages, 'hello-user', [ 'John "Wiki" <3 Doe' ] );
66 *
67 * obj.format = 'text';
68 * str = obj.toString();
69 * // Same as:
70 * str = obj.text();
71 *
72 * mw.log( str );
73 * // Hello, John "Wiki" <3 Doe!
74 *
75 * mw.log( obj.escaped() );
76 * // Hello, John &quot;Wiki&quot; &lt;3 Doe!
77 *
78 * @class mw.Message
79 *
80 * @constructor
81 * @param {mw.Map} map Message store
82 * @param {string} key
83 * @param {Array} [parameters]
84 */
85 function Message( map, key, parameters ) {
86 this.format = 'text';
87 this.map = map;
88 this.key = key;
89 this.parameters = parameters === undefined ? [] : slice.call( parameters );
90 return this;
91 }
92
93 Message.prototype = {
94 /**
95 * Get parsed contents of the message.
96 *
97 * The default parser does simple $N replacements and nothing else.
98 * This may be overridden to provide a more complex message parser.
99 * The primary override is in the mediawiki.jqueryMsg module.
100 *
101 * This function will not be called for nonexistent messages.
102 *
103 * @return {string} Parsed message
104 */
105 parser: function () {
106 return mw.format.apply( null, [ this.map.get( this.key ) ].concat( this.parameters ) );
107 },
108
109 /**
110 * Add (does not replace) parameters for `$N` placeholder values.
111 *
112 * @param {Array} parameters
113 * @return {mw.Message}
114 * @chainable
115 */
116 params: function ( parameters ) {
117 var i;
118 for ( i = 0; i < parameters.length; i++ ) {
119 this.parameters.push( parameters[ i ] );
120 }
121 return this;
122 },
123
124 /**
125 * Convert message object to its string form based on current format.
126 *
127 * @return {string} Message as a string in the current form, or `<key>` if key
128 * does not exist.
129 */
130 toString: function () {
131 var text;
132
133 if ( !this.exists() ) {
134 // Use ⧼key⧽ as text if key does not exist
135 // Err on the side of safety, ensure that the output
136 // is always html safe in the event the message key is
137 // missing, since in that case its highly likely the
138 // message key is user-controlled.
139 // '⧼' is used instead of '<' to side-step any
140 // double-escaping issues.
141 // (Keep synchronised with Message::toString() in PHP.)
142 return '⧼' + mw.html.escape( this.key ) + '⧽';
143 }
144
145 if ( this.format === 'plain' || this.format === 'text' || this.format === 'parse' ) {
146 text = this.parser();
147 }
148
149 if ( this.format === 'escaped' ) {
150 text = this.parser();
151 text = mw.html.escape( text );
152 }
153
154 return text;
155 },
156
157 /**
158 * Change format to 'parse' and convert message to string
159 *
160 * If jqueryMsg is loaded, this parses the message text from wikitext
161 * (where supported) to HTML
162 *
163 * Otherwise, it is equivalent to plain.
164 *
165 * @return {string} String form of parsed message
166 */
167 parse: function () {
168 this.format = 'parse';
169 return this.toString();
170 },
171
172 /**
173 * Change format to 'plain' and convert message to string
174 *
175 * This substitutes parameters, but otherwise does not change the
176 * message text.
177 *
178 * @return {string} String form of plain message
179 */
180 plain: function () {
181 this.format = 'plain';
182 return this.toString();
183 },
184
185 /**
186 * Change format to 'text' and convert message to string
187 *
188 * If jqueryMsg is loaded, {{-transformation is done where supported
189 * (such as {{plural:}}, {{gender:}}, {{int:}}).
190 *
191 * Otherwise, it is equivalent to plain
192 *
193 * @return {string} String form of text message
194 */
195 text: function () {
196 this.format = 'text';
197 return this.toString();
198 },
199
200 /**
201 * Change the format to 'escaped' and convert message to string
202 *
203 * This is equivalent to using the 'text' format (see #text), then
204 * HTML-escaping the output.
205 *
206 * @return {string} String form of html escaped message
207 */
208 escaped: function () {
209 this.format = 'escaped';
210 return this.toString();
211 },
212
213 /**
214 * Check if a message exists
215 *
216 * @see mw.Map#exists
217 * @return {boolean}
218 */
219 exists: function () {
220 return this.map.exists( this.key );
221 }
222 };
223
224 /**
225 * @class mw
226 * @singleton
227 */
228
229 /**
230 * @inheritdoc mw.inspect#runReports
231 * @method
232 */
233 mw.inspect = function () {
234 var args = arguments;
235 // Lazy-load
236 mw.loader.using( 'mediawiki.inspect', function () {
237 mw.inspect.runReports.apply( mw.inspect, args );
238 } );
239 };
240
241 /**
242 * Format a string. Replace $1, $2 ... $N with positional arguments.
243 *
244 * Used by Message#parser().
245 *
246 * @since 1.25
247 * @param {string} formatString Format string
248 * @param {...Mixed} parameters Values for $N replacements
249 * @return {string} Formatted string
250 */
251 mw.format = function ( formatString ) {
252 var parameters = slice.call( arguments, 1 );
253 return formatString.replace( /\$(\d+)/g, function ( str, match ) {
254 var index = parseInt( match, 10 ) - 1;
255 return parameters[ index ] !== undefined ? parameters[ index ] : '$' + match;
256 } );
257 };
258
259 // Expose Message constructor
260 mw.Message = Message;
261
262 /**
263 * Get a message object.
264 *
265 * Shortcut for `new mw.Message( mw.messages, key, parameters )`.
266 *
267 * @see mw.Message
268 * @param {string} key Key of message to get
269 * @param {...Mixed} parameters Values for $N replacements
270 * @return {mw.Message}
271 */
272 mw.message = function ( key ) {
273 var parameters = slice.call( arguments, 1 );
274 return new Message( mw.messages, key, parameters );
275 };
276
277 /**
278 * Get a message string using the (default) 'text' format.
279 *
280 * Shortcut for `mw.message( key, parameters... ).text()`.
281 *
282 * @see mw.Message
283 * @param {string} key Key of message to get
284 * @param {...Mixed} parameters Values for $N replacements
285 * @return {string}
286 */
287 mw.msg = function () {
288 return mw.message.apply( mw.message, arguments ).toString();
289 };
290
291 /**
292 * Track an analytic event.
293 *
294 * This method provides a generic means for MediaWiki JavaScript code to capture state
295 * information for analysis. Each logged event specifies a string topic name that describes
296 * the kind of event that it is. Topic names consist of dot-separated path components,
297 * arranged from most general to most specific. Each path component should have a clear and
298 * well-defined purpose.
299 *
300 * Data handlers are registered via `mw.trackSubscribe`, and receive the full set of
301 * events that match their subcription, including those that fired before the handler was
302 * bound.
303 *
304 * @param {string} topic Topic name
305 * @param {Object} [data] Data describing the event, encoded as an object
306 */
307 mw.track = function ( topic, data ) {
308 mwLoaderTrack( topic, data );
309 trackCallbacks.fire( mw.trackQueue );
310 };
311
312 /**
313 * Register a handler for subset of analytic events, specified by topic.
314 *
315 * Handlers will be called once for each tracked event, including any events that fired before the
316 * handler was registered; 'this' is set to a plain object with a 'timeStamp' property indicating
317 * the exact time at which the event fired, a string 'topic' property naming the event, and a
318 * 'data' property which is an object of event-specific data. The event topic and event data are
319 * also passed to the callback as the first and second arguments, respectively.
320 *
321 * @param {string} topic Handle events whose name starts with this string prefix
322 * @param {Function} callback Handler to call for each matching tracked event
323 * @param {string} callback.topic
324 * @param {Object} [callback.data]
325 */
326 mw.trackSubscribe = function ( topic, callback ) {
327 var seen = 0;
328 function handler( trackQueue ) {
329 var event;
330 for ( ; seen < trackQueue.length; seen++ ) {
331 event = trackQueue[ seen ];
332 if ( event.topic.indexOf( topic ) === 0 ) {
333 callback.call( event, event.topic, event.data );
334 }
335 }
336 }
337
338 trackHandlers.push( [ handler, callback ] );
339
340 trackCallbacks.add( handler );
341 };
342
343 /**
344 * Stop handling events for a particular handler
345 *
346 * @param {Function} callback
347 */
348 mw.trackUnsubscribe = function ( callback ) {
349 trackHandlers = trackHandlers.filter( function ( fns ) {
350 if ( fns[ 1 ] === callback ) {
351 trackCallbacks.remove( fns[ 0 ] );
352 // Ensure the tuple is removed to avoid holding on to closures
353 return false;
354 }
355 return true;
356 } );
357 };
358
359 // Fire events from before track() triggered fire()
360 trackCallbacks.fire( mw.trackQueue );
361
362 /**
363 * Registry and firing of events.
364 *
365 * MediaWiki has various interface components that are extended, enhanced
366 * or manipulated in some other way by extensions, gadgets and even
367 * in core itself.
368 *
369 * This framework helps streamlining the timing of when these other
370 * code paths fire their plugins (instead of using document-ready,
371 * which can and should be limited to firing only once).
372 *
373 * Features like navigating to other wiki pages, previewing an edit
374 * and editing itself – without a refresh – can then retrigger these
375 * hooks accordingly to ensure everything still works as expected.
376 *
377 * Example usage:
378 *
379 * mw.hook( 'wikipage.content' ).add( fn ).remove( fn );
380 * mw.hook( 'wikipage.content' ).fire( $content );
381 *
382 * Handlers can be added and fired for arbitrary event names at any time. The same
383 * event can be fired multiple times. The last run of an event is memorized
384 * (similar to `$(document).ready` and `$.Deferred().done`).
385 * This means if an event is fired, and a handler added afterwards, the added
386 * function will be fired right away with the last given event data.
387 *
388 * Like Deferreds and Promises, the mw.hook object is both detachable and chainable.
389 * Thus allowing flexible use and optimal maintainability and authority control.
390 * You can pass around the `add` and/or `fire` method to another piece of code
391 * without it having to know the event name (or `mw.hook` for that matter).
392 *
393 * var h = mw.hook( 'bar.ready' );
394 * new mw.Foo( .. ).fetch( { callback: h.fire } );
395 *
396 * Note: Events are documented with an underscore instead of a dot in the event
397 * name due to jsduck not supporting dots in that position.
398 *
399 * @class mw.hook
400 */
401 mw.hook = ( function () {
402 var lists = Object.create( null );
403
404 /**
405 * Create an instance of mw.hook.
406 *
407 * @method hook
408 * @member mw
409 * @param {string} name Name of hook.
410 * @return {mw.hook}
411 */
412 return function ( name ) {
413 var list = lists[ name ] || ( lists[ name ] = $.Callbacks( 'memory' ) );
414
415 return {
416 /**
417 * Register a hook handler
418 *
419 * @param {...Function} handler Function to bind.
420 * @chainable
421 */
422 add: list.add,
423
424 /**
425 * Unregister a hook handler
426 *
427 * @param {...Function} handler Function to unbind.
428 * @chainable
429 */
430 remove: list.remove,
431
432 /**
433 * Run a hook.
434 *
435 * @param {...Mixed} data
436 * @return {mw.hook}
437 * @chainable
438 */
439 fire: function () {
440 return list.fireWith.call( this, null, slice.call( arguments ) );
441 }
442 };
443 };
444 }() );
445
446 /**
447 * HTML construction helper functions
448 *
449 * @example
450 *
451 * var Html, output;
452 *
453 * Html = mw.html;
454 * output = Html.element( 'div', {}, new Html.Raw(
455 * Html.element( 'img', { src: '<' } )
456 * ) );
457 * mw.log( output ); // <div><img src="&lt;"/></div>
458 *
459 * @class mw.html
460 * @singleton
461 */
462 mw.html = ( function () {
463 function escapeCallback( s ) {
464 switch ( s ) {
465 case '\'':
466 return '&#039;';
467 case '"':
468 return '&quot;';
469 case '<':
470 return '&lt;';
471 case '>':
472 return '&gt;';
473 case '&':
474 return '&amp;';
475 }
476 }
477
478 return {
479 /**
480 * Escape a string for HTML.
481 *
482 * Converts special characters to HTML entities.
483 *
484 * mw.html.escape( '< > \' & "' );
485 * // Returns &lt; &gt; &#039; &amp; &quot;
486 *
487 * @param {string} s The string to escape
488 * @return {string} HTML
489 */
490 escape: function ( s ) {
491 return s.replace( /['"<>&]/g, escapeCallback );
492 },
493
494 /**
495 * Create an HTML element string, with safe escaping.
496 *
497 * @param {string} name The tag name.
498 * @param {Object} [attrs] An object with members mapping element names to values
499 * @param {string|mw.html.Raw|mw.html.Cdata|null} [contents=null] The contents of the element.
500 *
501 * - string: Text to be escaped.
502 * - null: The element is treated as void with short closing form, e.g. `<br/>`.
503 * - this.Raw: The raw value is directly included.
504 * - this.Cdata: The raw value is directly included. An exception is
505 * thrown if it contains any illegal ETAGO delimiter.
506 * See <https://www.w3.org/TR/html401/appendix/notes.html#h-B.3.2>.
507 * @return {string} HTML
508 */
509 element: function ( name, attrs, contents ) {
510 var v, attrName, s = '<' + name;
511
512 if ( attrs ) {
513 for ( attrName in attrs ) {
514 v = attrs[ attrName ];
515 // Convert name=true, to name=name
516 if ( v === true ) {
517 v = attrName;
518 // Skip name=false
519 } else if ( v === false ) {
520 continue;
521 }
522 s += ' ' + attrName + '="' + this.escape( String( v ) ) + '"';
523 }
524 }
525 if ( contents === undefined || contents === null ) {
526 // Self close tag
527 s += '/>';
528 return s;
529 }
530 // Regular open tag
531 s += '>';
532 switch ( typeof contents ) {
533 case 'string':
534 // Escaped
535 s += this.escape( contents );
536 break;
537 case 'number':
538 case 'boolean':
539 // Convert to string
540 s += String( contents );
541 break;
542 default:
543 if ( contents instanceof this.Raw ) {
544 // Raw HTML inclusion
545 s += contents.value;
546 } else if ( contents instanceof this.Cdata ) {
547 // CDATA
548 if ( /<\/[a-zA-z]/.test( contents.value ) ) {
549 throw new Error( 'Illegal end tag found in CDATA' );
550 }
551 s += contents.value;
552 } else {
553 throw new Error( 'Invalid type of contents' );
554 }
555 }
556 s += '</' + name + '>';
557 return s;
558 },
559
560 /**
561 * Wrapper object for raw HTML passed to mw.html.element().
562 *
563 * @class mw.html.Raw
564 * @constructor
565 * @param {string} value
566 */
567 Raw: function ( value ) {
568 this.value = value;
569 },
570
571 /**
572 * Wrapper object for CDATA element contents passed to mw.html.element()
573 *
574 * @class mw.html.Cdata
575 * @constructor
576 * @param {string} value
577 */
578 Cdata: function ( value ) {
579 this.value = value;
580 }
581 };
582 }() );
583
584 /**
585 * Execute a function as soon as one or more required modules are ready.
586 *
587 * Example of inline dependency on OOjs:
588 *
589 * mw.loader.using( 'oojs', function () {
590 * OO.compare( [ 1 ], [ 1 ] );
591 * } );
592 *
593 * Example of inline dependency obtained via `require()`:
594 *
595 * mw.loader.using( [ 'mediawiki.util' ], function ( require ) {
596 * var util = require( 'mediawiki.util' );
597 * } );
598 *
599 * Since MediaWiki 1.23 this also returns a promise.
600 *
601 * Since MediaWiki 1.28 the promise is resolved with a `require` function.
602 *
603 * @member mw.loader
604 * @param {string|Array} dependencies Module name or array of modules names the
605 * callback depends on to be ready before executing
606 * @param {Function} [ready] Callback to execute when all dependencies are ready
607 * @param {Function} [error] Callback to execute if one or more dependencies failed
608 * @return {jQuery.Promise} With a `require` function
609 */
610 mw.loader.using = function ( dependencies, ready, error ) {
611 var deferred = $.Deferred();
612
613 // Allow calling with a single dependency as a string
614 if ( typeof dependencies === 'string' ) {
615 dependencies = [ dependencies ];
616 }
617
618 if ( ready ) {
619 deferred.done( ready );
620 }
621 if ( error ) {
622 deferred.fail( error );
623 }
624
625 try {
626 // Resolve entire dependency map
627 dependencies = mw.loader.resolve( dependencies );
628 } catch ( e ) {
629 return deferred.reject( e ).promise();
630 }
631
632 mw.loader.enqueue( dependencies, function () {
633 deferred.resolve( mw.loader.require );
634 }, deferred.reject );
635
636 return deferred.promise();
637 };
638
639 // Alias $j to jQuery for backwards compatibility
640 // @deprecated since 1.23 Use $ or jQuery instead
641 mw.log.deprecate( window, '$j', $, 'Use $ or jQuery instead.' );
642
643 // Process callbacks for Grade A that require modules.
644 // Plain ones were already processed by startup.js.
645 queue = window.RLQ;
646 // Redefine publicly to capture any late arrivals
647 window.RLQ = {
648 push: function ( entry ) {
649 mw.loader.using( entry[ 0 ], entry[ 1 ] );
650 }
651 };
652 while ( queue[ 0 ] ) {
653 window.RLQ.push( queue.shift() );
654 }
655 }() );