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