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