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