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