Merge "Use $wgUser in ResourceLoaderUserGroupsModule when possible."
[lhc/web/wiklou.git] / resources / mediawiki / mediawiki.jqueryMsg.js
1 /**
2 * Experimental advanced wikitext parser-emitter.
3 * See: http://www.mediawiki.org/wiki/Extension:UploadWizard/MessageParser for docs
4 *
5 * @author neilk@wikimedia.org
6 */
7
8 ( function( mw, $, undefined ) {
9
10 mw.jqueryMsg = {};
11
12 /**
13 * Given parser options, return a function that parses a key and replacements, returning jQuery object
14 * @param {Object} parser options
15 * @return {Function} accepting ( String message key, String replacement1, String replacement2 ... ) and returning {jQuery}
16 */
17 function getFailableParserFn( options ) {
18 var parser = new mw.jqueryMsg.parser( options );
19 /**
20 * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
21 * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
22 * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
23 *
24 * @param {Array} first element is the key, replacements may be in array in 2nd element, or remaining elements.
25 * @return {jQuery}
26 */
27 return function( args ) {
28 var key = args[0];
29 var argsArray = $.isArray( args[1] ) ? args[1] : $.makeArray( args ).slice( 1 );
30 var escapedArgsArray = $.map( argsArray, function( arg ) {
31 return typeof arg === 'string' ? mw.html.escape( arg ) : arg;
32 } );
33 try {
34 return parser.parse( key, escapedArgsArray );
35 } catch ( e ) {
36 return $( '<span></span>' ).append( key + ': ' + e.message );
37 }
38 };
39 }
40
41 /**
42 * Class method.
43 * Returns a function suitable for use as a global, to construct strings from the message key (and optional replacements).
44 * e.g.
45 * window.gM = mediaWiki.parser.getMessageFunction( options );
46 * $( 'p#headline' ).html( gM( 'hello-user', username ) );
47 *
48 * Like the old gM() function this returns only strings, so it destroys any bindings. If you want to preserve bindings use the
49 * jQuery plugin version instead. This is only included for backwards compatibility with gM().
50 *
51 * @param {Array} parser options
52 * @return {Function} function suitable for assigning to window.gM
53 */
54 mw.jqueryMsg.getMessageFunction = function( options ) {
55 var failableParserFn = getFailableParserFn( options );
56 /**
57 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
58 * somefunction(a, b, c, d)
59 * is equivalent to
60 * somefunction(a, [b, c, d])
61 *
62 * @param {String} message key
63 * @param {Array} optional replacements (can also specify variadically)
64 * @return {String} rendered HTML as string
65 */
66 return function( /* key, replacements */ ) {
67 return failableParserFn( arguments ).html();
68 };
69 };
70
71 /**
72 * Class method.
73 * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
74 * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
75 * e.g.
76 * $.fn.msg = mediaWiki.parser.getJqueryPlugin( options );
77 * var userlink = $( '<a>' ).click( function() { alert( "hello!!") } );
78 * $( 'p#headline' ).msg( 'hello-user', userlink );
79 *
80 * @param {Array} parser options
81 * @return {Function} function suitable for assigning to jQuery plugin, such as $.fn.msg
82 */
83 mw.jqueryMsg.getPlugin = function( options ) {
84 var failableParserFn = getFailableParserFn( options );
85 /**
86 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
87 * somefunction(a, b, c, d)
88 * is equivalent to
89 * somefunction(a, [b, c, d])
90 *
91 * We append to 'this', which in a jQuery plugin context will be the selected elements.
92 * @param {String} message key
93 * @param {Array} optional replacements (can also specify variadically)
94 * @return {jQuery} this
95 */
96 return function( /* key, replacements */ ) {
97 var $target = this.empty();
98 $.each( failableParserFn( arguments ).contents(), function( i, node ) {
99 $target.append( node );
100 } );
101 return $target;
102 };
103 };
104
105 var parserDefaults = {
106 'magic' : {
107 'SITENAME' : mw.config.get( 'wgSiteName' )
108 },
109 'messages' : mw.messages,
110 'language' : mw.language
111 };
112
113 /**
114 * The parser itself.
115 * Describes an object, whose primary duty is to .parse() message keys.
116 * @param {Array} options
117 */
118 mw.jqueryMsg.parser = function( options ) {
119 this.settings = $.extend( {}, parserDefaults, options );
120 this.emitter = new mw.jqueryMsg.htmlEmitter( this.settings.language, this.settings.magic );
121 };
122
123 mw.jqueryMsg.parser.prototype = {
124
125 // cache, map of mediaWiki message key to the AST of the message. In most cases, the message is a string so this is identical.
126 // (This is why we would like to move this functionality server-side).
127 astCache: {},
128
129 /**
130 * Where the magic happens.
131 * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
132 * If an error is thrown, returns original key, and logs the error
133 * @param {String} message key
134 * @param {Array} replacements for $1, $2... $n
135 * @return {jQuery}
136 */
137 parse: function( key, replacements ) {
138 return this.emitter.emit( this.getAst( key ), replacements );
139 },
140
141 /**
142 * Fetch the message string associated with a key, return parsed structure. Memoized.
143 * Note that we pass '[' + key + ']' back for a missing message here.
144 * @param {String} key
145 * @return {String|Array} string of '[key]' if message missing, simple string if possible, array of arrays if needs parsing
146 */
147 getAst: function( key ) {
148 if ( this.astCache[ key ] === undefined ) {
149 var wikiText = this.settings.messages.get( key );
150 if ( typeof wikiText !== 'string' ) {
151 wikiText = "\\[" + key + "\\]";
152 }
153 this.astCache[ key ] = this.wikiTextToAst( wikiText );
154 }
155 return this.astCache[ key ];
156 },
157
158 /*
159 * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
160 *
161 * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
162 * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
163 *
164 * @param {String} message string wikitext
165 * @throws Error
166 * @return {Mixed} abstract syntax tree
167 */
168 wikiTextToAst: function( input ) {
169
170 // Indicates current position in input as we parse through it.
171 // Shared among all parsing functions below.
172 var pos = 0;
173
174 // =========================================================
175 // parsing combinators - could be a library on its own
176 // =========================================================
177
178
179 // Try parsers until one works, if none work return null
180 function choice( ps ) {
181 return function() {
182 for ( var i = 0; i < ps.length; i++ ) {
183 var result = ps[i]();
184 if ( result !== null ) {
185 return result;
186 }
187 }
188 return null;
189 };
190 }
191
192 // try several ps in a row, all must succeed or return null
193 // this is the only eager one
194 function sequence( ps ) {
195 var originalPos = pos;
196 var result = [];
197 for ( var i = 0; i < ps.length; i++ ) {
198 var res = ps[i]();
199 if ( res === null ) {
200 pos = originalPos;
201 return null;
202 }
203 result.push( res );
204 }
205 return result;
206 }
207
208 // run the same parser over and over until it fails.
209 // must succeed a minimum of n times or return null
210 function nOrMore( n, p ) {
211 return function() {
212 var originalPos = pos;
213 var result = [];
214 var parsed = p();
215 while ( parsed !== null ) {
216 result.push( parsed );
217 parsed = p();
218 }
219 if ( result.length < n ) {
220 pos = originalPos;
221 return null;
222 }
223 return result;
224 };
225 }
226
227 // There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
228 // But using this as a combinator seems to cause problems when combined with nOrMore().
229 // May be some scoping issue
230 function transform( p, fn ) {
231 return function() {
232 var result = p();
233 return result === null ? null : fn( result );
234 };
235 }
236
237 // Helpers -- just make ps out of simpler JS builtin types
238
239 function makeStringParser( s ) {
240 var len = s.length;
241 return function() {
242 var result = null;
243 if ( input.substr( pos, len ) === s ) {
244 result = s;
245 pos += len;
246 }
247 return result;
248 };
249 }
250
251 function makeRegexParser( regex ) {
252 return function() {
253 var matches = input.substr( pos ).match( regex );
254 if ( matches === null ) {
255 return null;
256 }
257 pos += matches[0].length;
258 return matches[0];
259 };
260 }
261
262
263 /**
264 * ===================================================================
265 * General patterns above this line -- wikitext specific parsers below
266 * ===================================================================
267 */
268
269 // Parsing functions follow. All parsing functions work like this:
270 // They don't accept any arguments.
271 // Instead, they just operate non destructively on the string 'input'
272 // As they can consume parts of the string, they advance the shared variable pos,
273 // and return tokens (or whatever else they want to return).
274
275 // some things are defined as closures and other things as ordinary functions
276 // converting everything to a closure makes it a lot harder to debug... errors pop up
277 // but some debuggers can't tell you exactly where they come from. Also the mutually
278 // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
279 // This may be because, to save code, memoization was removed
280
281
282 var regularLiteral = makeRegexParser( /^[^{}[\]$\\]/ );
283 var regularLiteralWithoutBar = makeRegexParser(/^[^{}[\]$\\|]/);
284 var regularLiteralWithoutSpace = makeRegexParser(/^[^{}[\]$\s]/);
285
286 var backslash = makeStringParser( "\\" );
287 var anyCharacter = makeRegexParser( /^./ );
288
289 function escapedLiteral() {
290 var result = sequence( [
291 backslash,
292 anyCharacter
293 ] );
294 return result === null ? null : result[1];
295 }
296
297 var escapedOrLiteralWithoutSpace = choice( [
298 escapedLiteral,
299 regularLiteralWithoutSpace
300 ] );
301
302 var escapedOrLiteralWithoutBar = choice( [
303 escapedLiteral,
304 regularLiteralWithoutBar
305 ] );
306
307 var escapedOrRegularLiteral = choice( [
308 escapedLiteral,
309 regularLiteral
310 ] );
311
312 // Used to define "literals" without spaces, in space-delimited situations
313 function literalWithoutSpace() {
314 var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
315 return result === null ? null : result.join('');
316 }
317
318 // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
319 // it is not a literal in the parameter
320 function literalWithoutBar() {
321 var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
322 return result === null ? null : result.join('');
323 }
324
325 function literal() {
326 var result = nOrMore( 1, escapedOrRegularLiteral )();
327 return result === null ? null : result.join('');
328 }
329
330 var whitespace = makeRegexParser( /^\s+/ );
331 var dollar = makeStringParser( '$' );
332 var digits = makeRegexParser( /^\d+/ );
333
334 function replacement() {
335 var result = sequence( [
336 dollar,
337 digits
338 ] );
339 if ( result === null ) {
340 return null;
341 }
342 return [ 'REPLACE', parseInt( result[1], 10 ) - 1 ];
343 }
344
345
346 var openExtlink = makeStringParser( '[' );
347 var closeExtlink = makeStringParser( ']' );
348
349 // this extlink MUST have inner text, e.g. [foo] not allowed; [foo bar] is allowed
350 function extlink() {
351 var result = null;
352 var parsedResult = sequence( [
353 openExtlink,
354 nonWhitespaceExpression,
355 whitespace,
356 expression,
357 closeExtlink
358 ] );
359 if ( parsedResult !== null ) {
360 result = [ 'LINK', parsedResult[1], parsedResult[3] ];
361 }
362 return result;
363 }
364
365 var openLink = makeStringParser( '[[' );
366 var closeLink = makeStringParser( ']]' );
367
368 function link() {
369 var result = null;
370 var parsedResult = sequence( [
371 openLink,
372 expression,
373 closeLink
374 ] );
375 if ( parsedResult !== null ) {
376 result = [ 'WLINK', parsedResult[1] ];
377 }
378 return result;
379 }
380
381 var templateName = transform(
382 // see $wgLegalTitleChars
383 // not allowing : due to the need to catch "PLURAL:$1"
384 makeRegexParser( /^[ !"$&'()*,.\/0-9;=?@A-Z\^_`a-z~\x80-\xFF+-]+/ ),
385 function( result ) { return result.toString(); }
386 );
387
388 function templateParam() {
389 var result = sequence( [
390 pipe,
391 nOrMore( 0, paramExpression )
392 ] );
393 if ( result === null ) {
394 return null;
395 }
396 var expr = result[1];
397 // use a "CONCAT" operator if there are multiple nodes, otherwise return the first node, raw.
398 return expr.length > 1 ? [ "CONCAT" ].concat( expr ) : expr[0];
399 }
400
401 var pipe = makeStringParser( '|' );
402
403 function templateWithReplacement() {
404 var result = sequence( [
405 templateName,
406 colon,
407 replacement
408 ] );
409 return result === null ? null : [ result[0], result[2] ];
410 }
411
412 function templateWithOutReplacement() {
413 var result = sequence( [
414 templateName,
415 colon,
416 paramExpression
417 ] );
418 return result === null ? null : [ result[0], result[2] ];
419 }
420
421 var colon = makeStringParser(':');
422
423 var templateContents = choice( [
424 function() {
425 var res = sequence( [
426 // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
427 // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
428 choice( [ templateWithReplacement, templateWithOutReplacement ] ),
429 nOrMore( 0, templateParam )
430 ] );
431 return res === null ? null : res[0].concat( res[1] );
432 },
433 function() {
434 var res = sequence( [
435 templateName,
436 nOrMore( 0, templateParam )
437 ] );
438 if ( res === null ) {
439 return null;
440 }
441 return [ res[0] ].concat( res[1] );
442 }
443 ] );
444
445 var openTemplate = makeStringParser('{{');
446 var closeTemplate = makeStringParser('}}');
447
448 function template() {
449 var result = sequence( [
450 openTemplate,
451 templateContents,
452 closeTemplate
453 ] );
454 return result === null ? null : result[1];
455 }
456
457 var nonWhitespaceExpression = choice( [
458 template,
459 link,
460 extlink,
461 replacement,
462 literalWithoutSpace
463 ] );
464
465 var paramExpression = choice( [
466 template,
467 link,
468 extlink,
469 replacement,
470 literalWithoutBar
471 ] );
472
473 var expression = choice( [
474 template,
475 link,
476 extlink,
477 replacement,
478 literal
479 ] );
480
481 function start() {
482 var result = nOrMore( 0, expression )();
483 if ( result === null ) {
484 return null;
485 }
486 return [ "CONCAT" ].concat( result );
487 }
488
489 // everything above this point is supposed to be stateless/static, but
490 // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
491
492 // finally let's do some actual work...
493
494 var result = start();
495
496 /*
497 * For success, the p must have gotten to the end of the input
498 * and returned a non-null.
499 * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
500 */
501 if (result === null || pos !== input.length) {
502 throw new Error( "Parse error at position " + pos.toString() + " in input: " + input );
503 }
504 return result;
505 }
506
507 };
508
509 /**
510 * htmlEmitter - object which primarily exists to emit HTML from parser ASTs
511 */
512 mw.jqueryMsg.htmlEmitter = function( language, magic ) {
513 this.language = language;
514 var _this = this;
515
516 $.each( magic, function( key, val ) {
517 _this[ key.toLowerCase() ] = function() { return val; };
518 } );
519
520 /**
521 * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
522 * Walk entire node structure, applying replacements and template functions when appropriate
523 * @param {Mixed} abstract syntax tree (top node or subnode)
524 * @param {Array} replacements for $1, $2, ... $n
525 * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
526 */
527 this.emit = function( node, replacements ) {
528 var ret = null;
529 var _this = this;
530 switch( typeof node ) {
531 case 'string':
532 case 'number':
533 ret = node;
534 break;
535 case 'object': // node is an array of nodes
536 var subnodes = $.map( node.slice( 1 ), function( n ) {
537 return _this.emit( n, replacements );
538 } );
539 var operation = node[0].toLowerCase();
540 if ( typeof _this[operation] === 'function' ) {
541 ret = _this[ operation ]( subnodes, replacements );
542 } else {
543 throw new Error( 'unknown operation "' + operation + '"' );
544 }
545 break;
546 case 'undefined':
547 // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
548 // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
549 // The logical thing is probably to return the empty string here when we encounter undefined.
550 ret = '';
551 break;
552 default:
553 throw new Error( 'unexpected type in AST: ' + typeof node );
554 }
555 return ret;
556 };
557
558 };
559
560 // For everything in input that follows double-open-curly braces, there should be an equivalent parser
561 // function. For instance {{PLURAL ... }} will be processed by 'plural'.
562 // If you have 'magic words' then configure the parser to have them upon creation.
563 //
564 // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
565 // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
566 mw.jqueryMsg.htmlEmitter.prototype = {
567
568 /**
569 * Parsing has been applied depth-first we can assume that all nodes here are single nodes
570 * Must return a single node to parents -- a jQuery with synthetic span
571 * However, unwrap any other synthetic spans in our children and pass them upwards
572 * @param {Array} nodes - mixed, some single nodes, some arrays of nodes
573 * @return {jQuery}
574 */
575 concat: function( nodes ) {
576 var span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
577 $.each( nodes, function( i, node ) {
578 if ( node instanceof jQuery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) {
579 $.each( node.contents(), function( j, childNode ) {
580 span.append( childNode );
581 } );
582 } else {
583 // strings, integers, anything else
584 span.append( node );
585 }
586 } );
587 return span;
588 },
589
590 /**
591 * Return replacement of correct index, or string if unavailable.
592 * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
593 * if the specified parameter is not found return the same string
594 * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
595 * TODO throw error if nodes.length > 1 ?
596 * @param {Array} of one element, integer, n >= 0
597 * @return {String} replacement
598 */
599 replace: function( nodes, replacements ) {
600 var index = parseInt( nodes[0], 10 );
601 return index < replacements.length ? replacements[index] : '$' + ( index + 1 );
602 },
603
604 /**
605 * Transform wiki-link
606 * TODO unimplemented
607 */
608 wlink: function( nodes ) {
609 return "unimplemented";
610 },
611
612 /**
613 * Transform parsed structure into external link
614 * If the href is a jQuery object, treat it as "enclosing" the link text.
615 * ... function, treat it as the click handler
616 * ... string, treat it as a URI
617 * TODO: throw an error if nodes.length > 2 ?
618 * @param {Array} of two elements, {jQuery|Function|String} and {String}
619 * @return {jQuery}
620 */
621 link: function( nodes ) {
622 var arg = nodes[0];
623 var contents = nodes[1];
624 var $el;
625 if ( arg instanceof jQuery ) {
626 $el = arg;
627 } else {
628 $el = $( '<a>' );
629 if ( typeof arg === 'function' ) {
630 $el.click( arg ).attr( 'href', '#' );
631 } else {
632 $el.attr( 'href', arg.toString() );
633 }
634 }
635 $el.append( contents );
636 return $el;
637 },
638
639 /**
640 * Transform parsed structure into pluralization
641 * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
642 * So convert it back with the current language's convertNumber.
643 * @param {Array} of nodes, [ {String|Number}, {String}, {String} ... ]
644 * @return {String} selected pluralized form according to current language
645 */
646 plural: function( nodes ) {
647 var count = parseInt( this.language.convertNumber( nodes[0], true ), 10 );
648 var forms = nodes.slice(1);
649 return forms.length ? this.language.convertPlural( count, forms ) : '';
650 },
651
652 /**
653 * Transform parsed structure into gender
654 * Usage {{gender:[gender| mw.user object ] | masculine|feminine|neutral}}.
655 * @param {Array} of nodes, [ {String|mw.User}, {String}, {String} , {String} ]
656 * @return {String} selected gender form according to current language
657 */
658 gender: function( nodes ) {
659 var gender;
660 if ( nodes[0] && nodes[0].options instanceof mw.Map ){
661 gender = nodes[0].options.get( 'gender' );
662 } else {
663 gender = nodes[0];
664 }
665 var forms = nodes.slice(1);
666 return this.language.gender( gender, forms );
667 },
668
669 /**
670 * Transform parsed structure into grammar conversion.
671 * Invoked by putting {{grammar:form|word}} in a message
672 * @param {Array} of nodes [{Grammar case eg: genitive}, {String word}]
673 * @return {String} selected grammatical form according to current language
674 */
675 grammar: function( nodes ) {
676 var form = nodes[0];
677 var word = nodes[1];
678 return word && form && this.language.convertGrammar( word, form );
679 }
680 };
681
682 // deprecated! don't rely on gM existing.
683 // the window.gM ought not to be required - or if required, not required here. But moving it to extensions breaks it (?!)
684 // Need to fix plugin so it could do attributes as well, then will be okay to remove this.
685 window.gM = mw.jqueryMsg.getMessageFunction();
686
687 $.fn.msg = mw.jqueryMsg.getPlugin();
688
689 // Replace the default message parser with jqueryMsg
690 var oldParser = mw.Message.prototype.parser;
691 mw.Message.prototype.parser = function() {
692 // TODO: should we cache the message function so we don't create a new one every time? Benchmark this maybe?
693 // Caching is somewhat problematic, because we do need different message functions for different maps, so
694 // we'd have to cache the parser as a member of this.map, which sounds a bit ugly.
695
696 // Do not use mw.jqueryMsg unless required
697 if ( this.map.get( this.key ).indexOf( '{{' ) < 0 ) {
698 // Fall back to mw.msg's simple parser
699 return oldParser.apply( this );
700 }
701
702 var messageFunction = mw.jqueryMsg.getMessageFunction( { 'messages': this.map } );
703 return messageFunction( this.key, this.parameters );
704 };
705
706 } )( mediaWiki, jQuery );