Merge "Language: s/error_log/wfWarn/"
[lhc/web/wiklou.git] / resources / src / mediawiki / mediawiki.jqueryMsg.js
1 /*!
2 * Experimental advanced wikitext parser-emitter.
3 * See: https://www.mediawiki.org/wiki/Extension:UploadWizard/MessageParser for docs
4 *
5 * @author neilk@wikimedia.org
6 * @author mflaschen@wikimedia.org
7 */
8 ( function ( mw, $ ) {
9 /**
10 * @class mw.jqueryMsg
11 * @singleton
12 */
13
14 var oldParser,
15 slice = Array.prototype.slice,
16 parserDefaults = {
17 magic: {
18 'SITENAME': mw.config.get( 'wgSiteName' )
19 },
20 // This is a whitelist based on, but simpler than, Sanitizer.php.
21 // Self-closing tags are not currently supported.
22 allowedHtmlElements: [
23 'b',
24 'i'
25 ],
26 // Key tag name, value allowed attributes for that tag.
27 // See Sanitizer::setupAttributeWhitelist
28 allowedHtmlCommonAttributes: [
29 // HTML
30 'id',
31 'class',
32 'style',
33 'lang',
34 'dir',
35 'title',
36
37 // WAI-ARIA
38 'role'
39 ],
40
41 // Attributes allowed for specific elements.
42 // Key is element name in lower case
43 // Value is array of allowed attributes for that element
44 allowedHtmlAttributesByElement: {},
45 messages: mw.messages,
46 language: mw.language,
47
48 // Same meaning as in mediawiki.js.
49 //
50 // Only 'text', 'parse', and 'escaped' are supported, and the
51 // actual escaping for 'escaped' is done by other code (generally
52 // through mediawiki.js).
53 //
54 // However, note that this default only
55 // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
56 // is 'text', including when it uses jqueryMsg.
57 format: 'parse'
58
59 };
60
61 /**
62 * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
63 * convert what it detects as an htmlString to an element.
64 *
65 * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
66 *
67 * @private
68 * @param {jQuery} $parent Parent node wrapped by jQuery
69 * @param {Object|string|Array} children What to append, with the same possible types as jQuery
70 * @return {jQuery} $parent
71 */
72 function appendWithoutParsing( $parent, children ) {
73 var i, len;
74
75 if ( !$.isArray( children ) ) {
76 children = [children];
77 }
78
79 for ( i = 0, len = children.length; i < len; i++ ) {
80 if ( typeof children[i] !== 'object' ) {
81 children[i] = document.createTextNode( children[i] );
82 }
83 }
84
85 return $parent.append( children );
86 }
87
88 /**
89 * Decodes the main HTML entities, those encoded by mw.html.escape.
90 *
91 * @private
92 * @param {string} encoded Encoded string
93 * @return {string} String with those entities decoded
94 */
95 function decodePrimaryHtmlEntities( encoded ) {
96 return encoded
97 .replace( /&#039;/g, '\'' )
98 .replace( /&quot;/g, '"' )
99 .replace( /&lt;/g, '<' )
100 .replace( /&gt;/g, '>' )
101 .replace( /&amp;/g, '&' );
102 }
103
104 /**
105 * Given parser options, return a function that parses a key and replacements, returning jQuery object
106 *
107 * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
108 * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
109 * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
110 * @private
111 * @param {Object} options Parser options
112 * @return {Function}
113 * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
114 * @return {jQuery} return.return
115 */
116 function getFailableParserFn( options ) {
117 var parser = new mw.jqueryMsg.parser( options );
118
119 return function ( args ) {
120 var fallback,
121 key = args[0],
122 argsArray = $.isArray( args[1] ) ? args[1] : slice.call( args, 1 );
123 try {
124 return parser.parse( key, argsArray );
125 } catch ( e ) {
126 fallback = parser.settings.messages.get( key );
127 mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
128 return $( '<span>' ).text( fallback );
129 }
130 };
131 }
132
133 mw.jqueryMsg = {};
134
135 /**
136 * Returns a function suitable for use as a global, to construct strings from the message key (and optional replacements).
137 * e.g.
138 *
139 * window.gM = mediaWiki.parser.getMessageFunction( options );
140 * $( 'p#headline' ).html( gM( 'hello-user', username ) );
141 *
142 * Like the old gM() function this returns only strings, so it destroys any bindings. If you want to preserve bindings use the
143 * jQuery plugin version instead. This is only included for backwards compatibility with gM().
144 *
145 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
146 * somefunction( a, b, c, d )
147 * is equivalent to
148 * somefunction( a, [b, c, d] )
149 *
150 * @param {Object} options parser options
151 * @return {Function} Function suitable for assigning to window.gM
152 * @return {string} return.key Message key.
153 * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
154 * @return {string} return.return Rendered HTML.
155 */
156 mw.jqueryMsg.getMessageFunction = function ( options ) {
157 var failableParserFn = getFailableParserFn( options ),
158 format;
159
160 if ( options && options.format !== undefined ) {
161 format = options.format;
162 } else {
163 format = parserDefaults.format;
164 }
165
166 return function () {
167 var failableResult = failableParserFn( arguments );
168 if ( format === 'text' || format === 'escaped' ) {
169 return failableResult.text();
170 } else {
171 return failableResult.html();
172 }
173 };
174 };
175
176 /**
177 * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
178 * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
179 * e.g.
180 *
181 * $.fn.msg = mediaWiki.parser.getJqueryPlugin( options );
182 * var userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
183 * $( 'p#headline' ).msg( 'hello-user', userlink );
184 *
185 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
186 * somefunction( a, b, c, d )
187 * is equivalent to
188 * somefunction( a, [b, c, d] )
189 *
190 * We append to 'this', which in a jQuery plugin context will be the selected elements.
191 *
192 * @param {Object} options Parser options
193 * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
194 * @return {string} return.key Message key.
195 * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
196 * @return {jQuery} return.return
197 */
198 mw.jqueryMsg.getPlugin = function ( options ) {
199 var failableParserFn = getFailableParserFn( options );
200
201 return function () {
202 var $target = this.empty();
203 // TODO: Simply appendWithoutParsing( $target, failableParserFn( arguments ).contents() )
204 // or Simply appendWithoutParsing( $target, failableParserFn( arguments ) )
205 $.each( failableParserFn( arguments ).contents(), function ( i, node ) {
206 appendWithoutParsing( $target, node );
207 } );
208 return $target;
209 };
210 };
211
212 /**
213 * The parser itself.
214 * Describes an object, whose primary duty is to .parse() message keys.
215 *
216 * @class
217 * @private
218 * @param {Object} options
219 */
220 mw.jqueryMsg.parser = function ( options ) {
221 this.settings = $.extend( {}, parserDefaults, options );
222 this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
223
224 this.emitter = new mw.jqueryMsg.htmlEmitter( this.settings.language, this.settings.magic );
225 };
226
227 mw.jqueryMsg.parser.prototype = {
228 /**
229 * Cache mapping MediaWiki message keys and the value onlyCurlyBraceTransform, to the AST of the message.
230 *
231 * In most cases, the message is a string so this is identical.
232 * (This is why we would like to move this functionality server-side).
233 *
234 * The two parts of the key are separated by colon. For example:
235 *
236 * "message-key:true": ast
237 *
238 * if they key is "message-key" and onlyCurlyBraceTransform is true.
239 *
240 * This cache is shared by all instances of mw.jqueryMsg.parser.
241 *
242 * NOTE: We promise, it's static - when you create this empty object
243 * in the prototype, each new instance of the class gets a reference
244 * to the same object.
245 *
246 * @static
247 * @property {Object}
248 */
249 astCache: {},
250
251 /**
252 * Where the magic happens.
253 * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
254 * If an error is thrown, returns original key, and logs the error
255 * @param {string} key Message key.
256 * @param {Array} replacements Variable replacements for $1, $2... $n
257 * @return {jQuery}
258 */
259 parse: function ( key, replacements ) {
260 return this.emitter.emit( this.getAst( key ), replacements );
261 },
262
263 /**
264 * Fetch the message string associated with a key, return parsed structure. Memoized.
265 * Note that we pass '[' + key + ']' back for a missing message here.
266 * @param {string} key
267 * @return {string|Array} string of '[key]' if message missing, simple string if possible, array of arrays if needs parsing
268 */
269 getAst: function ( key ) {
270 var cacheKey = [key, this.settings.onlyCurlyBraceTransform].join( ':' ), wikiText;
271
272 if ( this.astCache[ cacheKey ] === undefined ) {
273 wikiText = this.settings.messages.get( key );
274 if ( typeof wikiText !== 'string' ) {
275 wikiText = '\\[' + key + '\\]';
276 }
277 this.astCache[ cacheKey ] = this.wikiTextToAst( wikiText );
278 }
279 return this.astCache[ cacheKey ];
280 },
281
282 /**
283 * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
284 *
285 * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
286 * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
287 *
288 * @param {string} input Message string wikitext
289 * @throws Error
290 * @return {Mixed} abstract syntax tree
291 */
292 wikiTextToAst: function ( input ) {
293 var pos, settings = this.settings, concat = Array.prototype.concat,
294 regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
295 doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
296 escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
297 whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
298 htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
299 openExtlink, closeExtlink, wikilinkPage, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
300 templateContents, openTemplate, closeTemplate,
301 nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result;
302
303 // Indicates current position in input as we parse through it.
304 // Shared among all parsing functions below.
305 pos = 0;
306
307 // =========================================================
308 // parsing combinators - could be a library on its own
309 // =========================================================
310
311 /**
312 * Try parsers until one works, if none work return null
313 * @private
314 * @param {Function[]} ps
315 * @return {string|null}
316 */
317 function choice( ps ) {
318 return function () {
319 var i, result;
320 for ( i = 0; i < ps.length; i++ ) {
321 result = ps[i]();
322 if ( result !== null ) {
323 return result;
324 }
325 }
326 return null;
327 };
328 }
329
330 /**
331 * Try several ps in a row, all must succeed or return null.
332 * This is the only eager one.
333 * @private
334 * @param {Function[]} ps
335 * @return {string|null}
336 */
337 function sequence( ps ) {
338 var i, res,
339 originalPos = pos,
340 result = [];
341 for ( i = 0; i < ps.length; i++ ) {
342 res = ps[i]();
343 if ( res === null ) {
344 pos = originalPos;
345 return null;
346 }
347 result.push( res );
348 }
349 return result;
350 }
351
352 /**
353 * Run the same parser over and over until it fails.
354 * Must succeed a minimum of n times or return null.
355 * @private
356 * @param {number} n
357 * @param {Function} p
358 * @return {string|null}
359 */
360 function nOrMore( n, p ) {
361 return function () {
362 var originalPos = pos,
363 result = [],
364 parsed = p();
365 while ( parsed !== null ) {
366 result.push( parsed );
367 parsed = p();
368 }
369 if ( result.length < n ) {
370 pos = originalPos;
371 return null;
372 }
373 return result;
374 };
375 }
376
377 /**
378 * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
379 *
380 * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
381 * May be some scoping issue
382 *
383 * @private
384 * @param {Function} p
385 * @param {Function} fn
386 * @return {string|null}
387 */
388 function transform( p, fn ) {
389 return function () {
390 var result = p();
391 return result === null ? null : fn( result );
392 };
393 }
394
395 /**
396 * Just make parsers out of simpler JS builtin types
397 * @private
398 * @param {string} s
399 * @return {Function}
400 * @return {string} return.return
401 */
402 function makeStringParser( s ) {
403 var len = s.length;
404 return function () {
405 var result = null;
406 if ( input.substr( pos, len ) === s ) {
407 result = s;
408 pos += len;
409 }
410 return result;
411 };
412 }
413
414 /**
415 * Makes a regex parser, given a RegExp object.
416 * The regex being passed in should start with a ^ to anchor it to the start
417 * of the string.
418 *
419 * @private
420 * @param {RegExp} regex anchored regex
421 * @return {Function} function to parse input based on the regex
422 */
423 function makeRegexParser( regex ) {
424 return function () {
425 var matches = input.slice( pos ).match( regex );
426 if ( matches === null ) {
427 return null;
428 }
429 pos += matches[0].length;
430 return matches[0];
431 };
432 }
433
434 // ===================================================================
435 // General patterns above this line -- wikitext specific parsers below
436 // ===================================================================
437
438 // Parsing functions follow. All parsing functions work like this:
439 // They don't accept any arguments.
440 // Instead, they just operate non destructively on the string 'input'
441 // As they can consume parts of the string, they advance the shared variable pos,
442 // and return tokens (or whatever else they want to return).
443 // some things are defined as closures and other things as ordinary functions
444 // converting everything to a closure makes it a lot harder to debug... errors pop up
445 // but some debuggers can't tell you exactly where they come from. Also the mutually
446 // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
447 // This may be because, to save code, memoization was removed
448
449 regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
450 regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
451 regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
452 regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
453
454 backslash = makeStringParser( '\\' );
455 doubleQuote = makeStringParser( '"' );
456 singleQuote = makeStringParser( '\'' );
457 anyCharacter = makeRegexParser( /^./ );
458
459 openHtmlStartTag = makeStringParser( '<' );
460 optionalForwardSlash = makeRegexParser( /^\/?/ );
461 openHtmlEndTag = makeStringParser( '</' );
462 htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
463 closeHtmlTag = makeRegexParser( /^\s*>/ );
464
465 function escapedLiteral() {
466 var result = sequence( [
467 backslash,
468 anyCharacter
469 ] );
470 return result === null ? null : result[1];
471 }
472 escapedOrLiteralWithoutSpace = choice( [
473 escapedLiteral,
474 regularLiteralWithoutSpace
475 ] );
476 escapedOrLiteralWithoutBar = choice( [
477 escapedLiteral,
478 regularLiteralWithoutBar
479 ] );
480 escapedOrRegularLiteral = choice( [
481 escapedLiteral,
482 regularLiteral
483 ] );
484 // Used to define "literals" without spaces, in space-delimited situations
485 function literalWithoutSpace() {
486 var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
487 return result === null ? null : result.join( '' );
488 }
489 // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
490 // it is not a literal in the parameter
491 function literalWithoutBar() {
492 var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
493 return result === null ? null : result.join( '' );
494 }
495
496 // Used for wikilink page names. Like literalWithoutBar, but
497 // without allowing escapes.
498 function unescapedLiteralWithoutBar() {
499 var result = nOrMore( 1, regularLiteralWithoutBar )();
500 return result === null ? null : result.join( '' );
501 }
502
503 function literal() {
504 var result = nOrMore( 1, escapedOrRegularLiteral )();
505 return result === null ? null : result.join( '' );
506 }
507
508 function curlyBraceTransformExpressionLiteral() {
509 var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
510 return result === null ? null : result.join( '' );
511 }
512
513 asciiAlphabetLiteral = makeRegexParser( /[A-Za-z]+/ );
514 htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
515 htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
516
517 whitespace = makeRegexParser( /^\s+/ );
518 dollar = makeStringParser( '$' );
519 digits = makeRegexParser( /^\d+/ );
520
521 function replacement() {
522 var result = sequence( [
523 dollar,
524 digits
525 ] );
526 if ( result === null ) {
527 return null;
528 }
529 return [ 'REPLACE', parseInt( result[1], 10 ) - 1 ];
530 }
531 openExtlink = makeStringParser( '[' );
532 closeExtlink = makeStringParser( ']' );
533 // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
534 function extlink() {
535 var result, parsedResult;
536 result = null;
537 parsedResult = sequence( [
538 openExtlink,
539 nonWhitespaceExpression,
540 whitespace,
541 nOrMore( 1, expression ),
542 closeExtlink
543 ] );
544 if ( parsedResult !== null ) {
545 result = [ 'EXTLINK', parsedResult[1] ];
546 // TODO (mattflaschen, 2013-03-22): Clean this up if possible.
547 // It's avoiding CONCAT for single nodes, so they at least doesn't get the htmlEmitter span.
548 if ( parsedResult[3].length === 1 ) {
549 result.push( parsedResult[3][0] );
550 } else {
551 result.push( ['CONCAT'].concat( parsedResult[3] ) );
552 }
553 }
554 return result;
555 }
556 // this is the same as the above extlink, except that the url is being passed on as a parameter
557 function extLinkParam() {
558 var result = sequence( [
559 openExtlink,
560 dollar,
561 digits,
562 whitespace,
563 expression,
564 closeExtlink
565 ] );
566 if ( result === null ) {
567 return null;
568 }
569 return [ 'EXTLINKPARAM', parseInt( result[2], 10 ) - 1, result[4] ];
570 }
571 openWikilink = makeStringParser( '[[' );
572 closeWikilink = makeStringParser( ']]' );
573 pipe = makeStringParser( '|' );
574
575 function template() {
576 var result = sequence( [
577 openTemplate,
578 templateContents,
579 closeTemplate
580 ] );
581 return result === null ? null : result[1];
582 }
583
584 wikilinkPage = choice( [
585 unescapedLiteralWithoutBar,
586 template
587 ] );
588
589 function pipedWikilink() {
590 var result = sequence( [
591 wikilinkPage,
592 pipe,
593 expression
594 ] );
595 return result === null ? null : [ result[0], result[2] ];
596 }
597
598 wikilinkContents = choice( [
599 pipedWikilink,
600 wikilinkPage // unpiped link
601 ] );
602
603 function wikilink() {
604 var result, parsedResult, parsedLinkContents;
605 result = null;
606
607 parsedResult = sequence( [
608 openWikilink,
609 wikilinkContents,
610 closeWikilink
611 ] );
612 if ( parsedResult !== null ) {
613 parsedLinkContents = parsedResult[1];
614 result = [ 'WIKILINK' ].concat( parsedLinkContents );
615 }
616 return result;
617 }
618
619 // TODO: Support data- if appropriate
620 function doubleQuotedHtmlAttributeValue() {
621 var parsedResult = sequence( [
622 doubleQuote,
623 htmlDoubleQuoteAttributeValue,
624 doubleQuote
625 ] );
626 return parsedResult === null ? null : parsedResult[1];
627 }
628
629 function singleQuotedHtmlAttributeValue() {
630 var parsedResult = sequence( [
631 singleQuote,
632 htmlSingleQuoteAttributeValue,
633 singleQuote
634 ] );
635 return parsedResult === null ? null : parsedResult[1];
636 }
637
638 function htmlAttribute() {
639 var parsedResult = sequence( [
640 whitespace,
641 asciiAlphabetLiteral,
642 htmlAttributeEquals,
643 choice( [
644 doubleQuotedHtmlAttributeValue,
645 singleQuotedHtmlAttributeValue
646 ] )
647 ] );
648 return parsedResult === null ? null : [parsedResult[1], parsedResult[3]];
649 }
650
651 /**
652 * Checks if HTML is allowed
653 *
654 * @param {string} startTagName HTML start tag name
655 * @param {string} endTagName HTML start tag name
656 * @param {Object} attributes array of consecutive key value pairs,
657 * with index 2 * n being a name and 2 * n + 1 the associated value
658 * @return {boolean} true if this is HTML is allowed, false otherwise
659 */
660 function isAllowedHtml( startTagName, endTagName, attributes ) {
661 var i, len, attributeName;
662
663 startTagName = startTagName.toLowerCase();
664 endTagName = endTagName.toLowerCase();
665 if ( startTagName !== endTagName || $.inArray( startTagName, settings.allowedHtmlElements ) === -1 ) {
666 return false;
667 }
668
669 for ( i = 0, len = attributes.length; i < len; i += 2 ) {
670 attributeName = attributes[i];
671 if ( $.inArray( attributeName, settings.allowedHtmlCommonAttributes ) === -1 &&
672 $.inArray( attributeName, settings.allowedHtmlAttributesByElement[startTagName] || [] ) === -1 ) {
673 return false;
674 }
675 }
676
677 return true;
678 }
679
680 function htmlAttributes() {
681 var parsedResult = nOrMore( 0, htmlAttribute )();
682 // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
683 return concat.apply( ['HTMLATTRIBUTES'], parsedResult );
684 }
685
686 // Subset of allowed HTML markup.
687 // Most elements and many attributes allowed on the server are not supported yet.
688 function html() {
689 var result = null, parsedOpenTagResult, parsedHtmlContents,
690 parsedCloseTagResult, wrappedAttributes, attributes,
691 startTagName, endTagName, startOpenTagPos, startCloseTagPos,
692 endOpenTagPos, endCloseTagPos;
693
694 // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
695 // 1. open through closeHtmlTag
696 // 2. expression
697 // 3. openHtmlEnd through close
698 // This will allow recording the positions to reconstruct if HTML is to be treated as text.
699
700 startOpenTagPos = pos;
701 parsedOpenTagResult = sequence( [
702 openHtmlStartTag,
703 asciiAlphabetLiteral,
704 htmlAttributes,
705 optionalForwardSlash,
706 closeHtmlTag
707 ] );
708
709 if ( parsedOpenTagResult === null ) {
710 return null;
711 }
712
713 endOpenTagPos = pos;
714 startTagName = parsedOpenTagResult[1];
715
716 parsedHtmlContents = nOrMore( 0, expression )();
717
718 startCloseTagPos = pos;
719 parsedCloseTagResult = sequence( [
720 openHtmlEndTag,
721 asciiAlphabetLiteral,
722 closeHtmlTag
723 ] );
724
725 if ( parsedCloseTagResult === null ) {
726 // Closing tag failed. Return the start tag and contents.
727 return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
728 .concat( parsedHtmlContents );
729 }
730
731 endCloseTagPos = pos;
732 endTagName = parsedCloseTagResult[1];
733 wrappedAttributes = parsedOpenTagResult[2];
734 attributes = wrappedAttributes.slice( 1 );
735 if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
736 result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
737 .concat( parsedHtmlContents );
738 } else {
739 // HTML is not allowed, so contents will remain how
740 // it was, while HTML markup at this level will be
741 // treated as text
742 // E.g. assuming script tags are not allowed:
743 //
744 // <script>[[Foo|bar]]</script>
745 //
746 // results in '&lt;script&gt;' and '&lt;/script&gt;'
747 // (not treated as an HTML tag), surrounding a fully
748 // parsed HTML link.
749 //
750 // Concatenate everything from the tag, flattening the contents.
751 result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
752 .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
753 }
754
755 return result;
756 }
757
758 templateName = transform(
759 // see $wgLegalTitleChars
760 // not allowing : due to the need to catch "PLURAL:$1"
761 makeRegexParser( /^[ !"$&'()*,.\/0-9;=?@A-Z\^_`a-z~\x80-\xFF+\-]+/ ),
762 function ( result ) { return result.toString(); }
763 );
764 function templateParam() {
765 var expr, result;
766 result = sequence( [
767 pipe,
768 nOrMore( 0, paramExpression )
769 ] );
770 if ( result === null ) {
771 return null;
772 }
773 expr = result[1];
774 // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
775 return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[0];
776 }
777
778 function templateWithReplacement() {
779 var result = sequence( [
780 templateName,
781 colon,
782 replacement
783 ] );
784 return result === null ? null : [ result[0], result[2] ];
785 }
786 function templateWithOutReplacement() {
787 var result = sequence( [
788 templateName,
789 colon,
790 paramExpression
791 ] );
792 return result === null ? null : [ result[0], result[2] ];
793 }
794 function templateWithOutFirstParameter() {
795 var result = sequence( [
796 templateName,
797 colon
798 ] );
799 return result === null ? null : [ result[0], '' ];
800 }
801 colon = makeStringParser( ':' );
802 templateContents = choice( [
803 function () {
804 var res = sequence( [
805 // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
806 // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
807 choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
808 nOrMore( 0, templateParam )
809 ] );
810 return res === null ? null : res[0].concat( res[1] );
811 },
812 function () {
813 var res = sequence( [
814 templateName,
815 nOrMore( 0, templateParam )
816 ] );
817 if ( res === null ) {
818 return null;
819 }
820 return [ res[0] ].concat( res[1] );
821 }
822 ] );
823 openTemplate = makeStringParser( '{{' );
824 closeTemplate = makeStringParser( '}}' );
825 nonWhitespaceExpression = choice( [
826 template,
827 wikilink,
828 extLinkParam,
829 extlink,
830 replacement,
831 literalWithoutSpace
832 ] );
833 paramExpression = choice( [
834 template,
835 wikilink,
836 extLinkParam,
837 extlink,
838 replacement,
839 literalWithoutBar
840 ] );
841
842 expression = choice( [
843 template,
844 wikilink,
845 extLinkParam,
846 extlink,
847 replacement,
848 html,
849 literal
850 ] );
851
852 // Used when only {{-transformation is wanted, for 'text'
853 // or 'escaped' formats
854 curlyBraceTransformExpression = choice( [
855 template,
856 replacement,
857 curlyBraceTransformExpressionLiteral
858 ] );
859
860 /**
861 * Starts the parse
862 *
863 * @param {Function} rootExpression root parse function
864 */
865 function start( rootExpression ) {
866 var result = nOrMore( 0, rootExpression )();
867 if ( result === null ) {
868 return null;
869 }
870 return [ 'CONCAT' ].concat( result );
871 }
872 // everything above this point is supposed to be stateless/static, but
873 // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
874 // finally let's do some actual work...
875
876 // If you add another possible rootExpression, you must update the astCache key scheme.
877 result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
878
879 /*
880 * For success, the p must have gotten to the end of the input
881 * and returned a non-null.
882 * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
883 */
884 if ( result === null || pos !== input.length ) {
885 throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
886 }
887 return result;
888 }
889
890 };
891
892 /**
893 * htmlEmitter - object which primarily exists to emit HTML from parser ASTs
894 */
895 mw.jqueryMsg.htmlEmitter = function ( language, magic ) {
896 this.language = language;
897 var jmsg = this;
898 $.each( magic, function ( key, val ) {
899 jmsg[ key.toLowerCase() ] = function () {
900 return val;
901 };
902 } );
903
904 /**
905 * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
906 * Walk entire node structure, applying replacements and template functions when appropriate
907 * @param {Mixed} node Abstract syntax tree (top node or subnode)
908 * @param {Array} replacements for $1, $2, ... $n
909 * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
910 */
911 this.emit = function ( node, replacements ) {
912 var ret, subnodes, operation,
913 jmsg = this;
914 switch ( typeof node ) {
915 case 'string':
916 case 'number':
917 ret = node;
918 break;
919 // typeof returns object for arrays
920 case 'object':
921 // node is an array of nodes
922 subnodes = $.map( node.slice( 1 ), function ( n ) {
923 return jmsg.emit( n, replacements );
924 } );
925 operation = node[0].toLowerCase();
926 if ( typeof jmsg[operation] === 'function' ) {
927 ret = jmsg[ operation ]( subnodes, replacements );
928 } else {
929 throw new Error( 'Unknown operation "' + operation + '"' );
930 }
931 break;
932 case 'undefined':
933 // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
934 // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
935 // The logical thing is probably to return the empty string here when we encounter undefined.
936 ret = '';
937 break;
938 default:
939 throw new Error( 'Unexpected type in AST: ' + typeof node );
940 }
941 return ret;
942 };
943 };
944
945 // For everything in input that follows double-open-curly braces, there should be an equivalent parser
946 // function. For instance {{PLURAL ... }} will be processed by 'plural'.
947 // If you have 'magic words' then configure the parser to have them upon creation.
948 //
949 // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
950 // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
951 mw.jqueryMsg.htmlEmitter.prototype = {
952 /**
953 * Parsing has been applied depth-first we can assume that all nodes here are single nodes
954 * Must return a single node to parents -- a jQuery with synthetic span
955 * However, unwrap any other synthetic spans in our children and pass them upwards
956 * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
957 * @return {jQuery}
958 */
959 concat: function ( nodes ) {
960 var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
961 $.each( nodes, function ( i, node ) {
962 if ( node instanceof jQuery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) {
963 $.each( node.contents(), function ( j, childNode ) {
964 appendWithoutParsing( $span, childNode );
965 } );
966 } else {
967 // Let jQuery append nodes, arrays of nodes and jQuery objects
968 // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
969 appendWithoutParsing( $span, node );
970 }
971 } );
972 return $span;
973 },
974
975 /**
976 * Return escaped replacement of correct index, or string if unavailable.
977 * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
978 * if the specified parameter is not found return the same string
979 * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
980 *
981 * TODO: Throw error if nodes.length > 1 ?
982 *
983 * @param {Array} nodes List of one element, integer, n >= 0
984 * @param {Array} replacements List of at least n strings
985 * @return {String} replacement
986 */
987 replace: function ( nodes, replacements ) {
988 var index = parseInt( nodes[0], 10 );
989
990 if ( index < replacements.length ) {
991 return replacements[index];
992 } else {
993 // index not found, fallback to displaying variable
994 return '$' + ( index + 1 );
995 }
996 },
997
998 /**
999 * Transform wiki-link
1000 *
1001 * TODO:
1002 * It only handles basic cases, either no pipe, or a pipe with an explicit
1003 * anchor.
1004 *
1005 * It does not attempt to handle features like the pipe trick.
1006 * However, the pipe trick should usually not be present in wikitext retrieved
1007 * from the server, since the replacement is done at save time.
1008 * It may, though, if the wikitext appears in extension-controlled content.
1009 *
1010 * @param nodes
1011 */
1012 wikilink: function ( nodes ) {
1013 var page, anchor, url;
1014
1015 page = nodes[0];
1016 url = mw.util.getUrl( page );
1017
1018 // [[Some Page]] or [[Namespace:Some Page]]
1019 if ( nodes.length === 1 ) {
1020 anchor = page;
1021 }
1022
1023 /*
1024 * [[Some Page|anchor text]] or
1025 * [[Namespace:Some Page|anchor]
1026 */
1027 else {
1028 anchor = nodes[1];
1029 }
1030
1031 return $( '<a>' ).attr( {
1032 title: page,
1033 href: url
1034 } ).text( anchor );
1035 },
1036
1037 /**
1038 * Converts array of HTML element key value pairs to object
1039 *
1040 * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
1041 * name and 2 * n + 1 the associated value
1042 * @return {Object} Object mapping attribute name to attribute value
1043 */
1044 htmlattributes: function ( nodes ) {
1045 var i, len, mapping = {};
1046 for ( i = 0, len = nodes.length; i < len; i += 2 ) {
1047 mapping[nodes[i]] = decodePrimaryHtmlEntities( nodes[i + 1] );
1048 }
1049 return mapping;
1050 },
1051
1052 /**
1053 * Handles an (already-validated) HTML element.
1054 *
1055 * @param {Array} nodes Nodes to process when creating element
1056 * @return {jQuery|Array} jQuery node for valid HTML or array for disallowed element
1057 */
1058 htmlelement: function ( nodes ) {
1059 var tagName, attributes, contents, $element;
1060
1061 tagName = nodes.shift();
1062 attributes = nodes.shift();
1063 contents = nodes;
1064 $element = $( document.createElement( tagName ) ).attr( attributes );
1065 return appendWithoutParsing( $element, contents );
1066 },
1067
1068 /**
1069 * Transform parsed structure into external link
1070 * If the href is a jQuery object, treat it as "enclosing" the link text.
1071 *
1072 * - ... function, treat it as the click handler.
1073 * - ... string, treat it as a URI.
1074 *
1075 * TODO: throw an error if nodes.length > 2 ?
1076 *
1077 * @param {Array} nodes List of two elements, {jQuery|Function|String} and {String}
1078 * @return {jQuery}
1079 */
1080 extlink: function ( nodes ) {
1081 var $el,
1082 arg = nodes[0],
1083 contents = nodes[1];
1084 if ( arg instanceof jQuery ) {
1085 $el = arg;
1086 } else {
1087 $el = $( '<a>' );
1088 if ( typeof arg === 'function' ) {
1089 $el.attr( 'href', '#' )
1090 .click( function ( e ) {
1091 e.preventDefault();
1092 } )
1093 .click( arg );
1094 } else {
1095 $el.attr( 'href', arg.toString() );
1096 }
1097 }
1098 return appendWithoutParsing( $el, contents );
1099 },
1100
1101 /**
1102 * This is basically use a combination of replace + external link (link with parameter
1103 * as url), but we don't want to run the regular replace here-on: inserting a
1104 * url as href-attribute of a link will automatically escape it already, so
1105 * we don't want replace to (manually) escape it as well.
1106 *
1107 * TODO: throw error if nodes.length > 1 ?
1108 *
1109 * @param {Array} nodes List of one element, integer, n >= 0
1110 * @param {Array} replacements List of at least n strings
1111 * @return {string} replacement
1112 */
1113 extlinkparam: function ( nodes, replacements ) {
1114 var replacement,
1115 index = parseInt( nodes[0], 10 );
1116 if ( index < replacements.length ) {
1117 replacement = replacements[index];
1118 } else {
1119 replacement = '$' + ( index + 1 );
1120 }
1121 return this.extlink( [ replacement, nodes[1] ] );
1122 },
1123
1124 /**
1125 * Transform parsed structure into pluralization
1126 * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
1127 * So convert it back with the current language's convertNumber.
1128 * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
1129 * @return {string} selected pluralized form according to current language
1130 */
1131 plural: function ( nodes ) {
1132 var forms, formIndex, node, count;
1133 count = parseFloat( this.language.convertNumber( nodes[0], true ) );
1134 forms = nodes.slice( 1 );
1135 for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
1136 node = forms[formIndex];
1137 if ( node.jquery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) {
1138 // This is a nested node, already expanded.
1139 forms[formIndex] = forms[formIndex].html();
1140 }
1141 }
1142 return forms.length ? this.language.convertPlural( count, forms ) : '';
1143 },
1144
1145 /**
1146 * Transform parsed structure according to gender.
1147 *
1148 * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
1149 *
1150 * The first node must be one of:
1151 * - the mw.user object (or a compatible one)
1152 * - an empty string - indicating the current user, same effect as passing the mw.user object
1153 * - a gender string ('male', 'female' or 'unknown')
1154 *
1155 * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
1156 * @return {string} Selected gender form according to current language
1157 */
1158 gender: function ( nodes ) {
1159 var gender,
1160 maybeUser = nodes[0],
1161 forms = nodes.slice( 1 );
1162
1163 if ( maybeUser === '' ) {
1164 maybeUser = mw.user;
1165 }
1166
1167 // If we are passed a mw.user-like object, check their gender.
1168 // Otherwise, assume the gender string itself was passed .
1169 if ( maybeUser && maybeUser.options instanceof mw.Map ) {
1170 gender = maybeUser.options.get( 'gender' );
1171 } else {
1172 gender = maybeUser;
1173 }
1174
1175 return this.language.gender( gender, forms );
1176 },
1177
1178 /**
1179 * Transform parsed structure into grammar conversion.
1180 * Invoked by putting `{{grammar:form|word}}` in a message
1181 * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
1182 * @return {string} selected grammatical form according to current language
1183 */
1184 grammar: function ( nodes ) {
1185 var form = nodes[0],
1186 word = nodes[1];
1187 return word && form && this.language.convertGrammar( word, form );
1188 },
1189
1190 /**
1191 * Tranform parsed structure into a int: (interface language) message include
1192 * Invoked by putting `{{int:othermessage}}` into a message
1193 * @param {Array} nodes List of nodes
1194 * @return {string} Other message
1195 */
1196 'int': function ( nodes ) {
1197 return mw.jqueryMsg.getMessageFunction()( nodes[0].toLowerCase() );
1198 },
1199
1200 /**
1201 * Takes an unformatted number (arab, no group separators and . as decimal separator)
1202 * and outputs it in the localized digit script and formatted with decimal
1203 * separator, according to the current language.
1204 * @param {Array} nodes List of nodes
1205 * @return {number|string} Formatted number
1206 */
1207 formatnum: function ( nodes ) {
1208 var isInteger = ( nodes[1] && nodes[1] === 'R' ) ? true : false,
1209 number = nodes[0];
1210
1211 return this.language.convertNumber( number, isInteger );
1212 }
1213 };
1214
1215 // Deprecated! don't rely on gM existing.
1216 // The window.gM ought not to be required - or if required, not required here.
1217 // But moving it to extensions breaks it (?!)
1218 // Need to fix plugin so it could do attributes as well, then will be okay to remove this.
1219 // @deprecated since 1.23
1220 mw.log.deprecate( window, 'gM', mw.jqueryMsg.getMessageFunction(), 'Use mw.message( ... ).parse() instead.' );
1221
1222 /**
1223 * @method
1224 * @member jQuery
1225 * @see mw.jqueryMsg#getPlugin
1226 */
1227 $.fn.msg = mw.jqueryMsg.getPlugin();
1228
1229 // Replace the default message parser with jqueryMsg
1230 oldParser = mw.Message.prototype.parser;
1231 mw.Message.prototype.parser = function () {
1232 var messageFunction;
1233
1234 // TODO: should we cache the message function so we don't create a new one every time? Benchmark this maybe?
1235 // Caching is somewhat problematic, because we do need different message functions for different maps, so
1236 // we'd have to cache the parser as a member of this.map, which sounds a bit ugly.
1237 // Do not use mw.jqueryMsg unless required
1238 if ( this.format === 'plain' || !/\{\{|[\[<>]/.test( this.map.get( this.key ) ) ) {
1239 // Fall back to mw.msg's simple parser
1240 return oldParser.apply( this );
1241 }
1242
1243 messageFunction = mw.jqueryMsg.getMessageFunction( {
1244 'messages': this.map,
1245 // For format 'escaped', escaping part is handled by mediawiki.js
1246 'format': this.format
1247 } );
1248 return messageFunction( this.key, this.parameters );
1249 };
1250
1251 }( mediaWiki, jQuery ) );