Merge "Send integer ms to DB lag time guage instead of seconds"
[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 regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
495 regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
496 regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
497 regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
498
499 backslash = makeStringParser( '\\' );
500 doubleQuote = makeStringParser( '"' );
501 singleQuote = makeStringParser( '\'' );
502 anyCharacter = makeRegexParser( /^./ );
503
504 openHtmlStartTag = makeStringParser( '<' );
505 optionalForwardSlash = makeRegexParser( /^\/?/ );
506 openHtmlEndTag = makeStringParser( '</' );
507 htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
508 closeHtmlTag = makeRegexParser( /^\s*>/ );
509
510 function escapedLiteral() {
511 var result = sequence( [
512 backslash,
513 anyCharacter
514 ] );
515 return result === null ? null : result[ 1 ];
516 }
517 escapedOrLiteralWithoutSpace = choice( [
518 escapedLiteral,
519 regularLiteralWithoutSpace
520 ] );
521 escapedOrLiteralWithoutBar = choice( [
522 escapedLiteral,
523 regularLiteralWithoutBar
524 ] );
525 escapedOrRegularLiteral = choice( [
526 escapedLiteral,
527 regularLiteral
528 ] );
529 // Used to define "literals" without spaces, in space-delimited situations
530 function literalWithoutSpace() {
531 var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
532 return result === null ? null : result.join( '' );
533 }
534 // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
535 // it is not a literal in the parameter
536 function literalWithoutBar() {
537 var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
538 return result === null ? null : result.join( '' );
539 }
540
541 function literal() {
542 var result = nOrMore( 1, escapedOrRegularLiteral )();
543 return result === null ? null : result.join( '' );
544 }
545
546 function curlyBraceTransformExpressionLiteral() {
547 var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
548 return result === null ? null : result.join( '' );
549 }
550
551 asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
552 htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
553 htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
554
555 whitespace = makeRegexParser( /^\s+/ );
556 dollar = makeStringParser( '$' );
557 digits = makeRegexParser( /^\d+/ );
558
559 function replacement() {
560 var result = sequence( [
561 dollar,
562 digits
563 ] );
564 if ( result === null ) {
565 return null;
566 }
567 return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
568 }
569 openExtlink = makeStringParser( '[' );
570 closeExtlink = makeStringParser( ']' );
571 // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
572 function extlink() {
573 var result, parsedResult, target;
574 result = null;
575 parsedResult = sequence( [
576 openExtlink,
577 nOrMore( 1, nonWhitespaceExpression ),
578 whitespace,
579 nOrMore( 1, expression ),
580 closeExtlink
581 ] );
582 if ( parsedResult !== null ) {
583 // When the entire link target is a single parameter, we can't use CONCAT, as we allow
584 // passing fancy parameters (like a whole jQuery object or a function) to use for the
585 // link. Check only if it's a single match, since we can either do CONCAT or not for
586 // singles with the same effect.
587 target = parsedResult[ 1 ].length === 1 ?
588 parsedResult[ 1 ][ 0 ] :
589 [ 'CONCAT' ].concat( parsedResult[ 1 ] );
590 result = [
591 'EXTLINK',
592 target,
593 [ 'CONCAT' ].concat( parsedResult[ 3 ] )
594 ];
595 }
596 return result;
597 }
598 openWikilink = makeStringParser( '[[' );
599 closeWikilink = makeStringParser( ']]' );
600 pipe = makeStringParser( '|' );
601
602 function template() {
603 var result = sequence( [
604 openTemplate,
605 templateContents,
606 closeTemplate
607 ] );
608 return result === null ? null : result[ 1 ];
609 }
610
611 function pipedWikilink() {
612 var result = sequence( [
613 nOrMore( 1, paramExpression ),
614 pipe,
615 nOrMore( 1, expression )
616 ] );
617 return result === null ? null : [
618 [ 'CONCAT' ].concat( result[ 0 ] ),
619 [ 'CONCAT' ].concat( result[ 2 ] )
620 ];
621 }
622
623 function unpipedWikilink() {
624 var result = sequence( [
625 nOrMore( 1, paramExpression )
626 ] );
627 return result === null ? null : [
628 [ 'CONCAT' ].concat( result[ 0 ] )
629 ];
630 }
631
632 wikilinkContents = choice( [
633 pipedWikilink,
634 unpipedWikilink
635 ] );
636
637 function wikilink() {
638 var result, parsedResult, parsedLinkContents;
639 result = null;
640
641 parsedResult = sequence( [
642 openWikilink,
643 wikilinkContents,
644 closeWikilink
645 ] );
646 if ( parsedResult !== null ) {
647 parsedLinkContents = parsedResult[ 1 ];
648 result = [ 'WIKILINK' ].concat( parsedLinkContents );
649 }
650 return result;
651 }
652
653 // TODO: Support data- if appropriate
654 function doubleQuotedHtmlAttributeValue() {
655 var parsedResult = sequence( [
656 doubleQuote,
657 htmlDoubleQuoteAttributeValue,
658 doubleQuote
659 ] );
660 return parsedResult === null ? null : parsedResult[ 1 ];
661 }
662
663 function singleQuotedHtmlAttributeValue() {
664 var parsedResult = sequence( [
665 singleQuote,
666 htmlSingleQuoteAttributeValue,
667 singleQuote
668 ] );
669 return parsedResult === null ? null : parsedResult[ 1 ];
670 }
671
672 function htmlAttribute() {
673 var parsedResult = sequence( [
674 whitespace,
675 asciiAlphabetLiteral,
676 htmlAttributeEquals,
677 choice( [
678 doubleQuotedHtmlAttributeValue,
679 singleQuotedHtmlAttributeValue
680 ] )
681 ] );
682 return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
683 }
684
685 /**
686 * Checks if HTML is allowed
687 *
688 * @param {string} startTagName HTML start tag name
689 * @param {string} endTagName HTML start tag name
690 * @param {Object} attributes array of consecutive key value pairs,
691 * with index 2 * n being a name and 2 * n + 1 the associated value
692 * @return {boolean} true if this is HTML is allowed, false otherwise
693 */
694 function isAllowedHtml( startTagName, endTagName, attributes ) {
695 var i, len, attributeName;
696
697 startTagName = startTagName.toLowerCase();
698 endTagName = endTagName.toLowerCase();
699 if ( startTagName !== endTagName || $.inArray( startTagName, settings.allowedHtmlElements ) === -1 ) {
700 return false;
701 }
702
703 for ( i = 0, len = attributes.length; i < len; i += 2 ) {
704 attributeName = attributes[ i ];
705 if ( $.inArray( attributeName, settings.allowedHtmlCommonAttributes ) === -1 &&
706 $.inArray( attributeName, settings.allowedHtmlAttributesByElement[ startTagName ] || [] ) === -1 ) {
707 return false;
708 }
709 }
710
711 return true;
712 }
713
714 function htmlAttributes() {
715 var parsedResult = nOrMore( 0, htmlAttribute )();
716 // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
717 return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
718 }
719
720 // Subset of allowed HTML markup.
721 // Most elements and many attributes allowed on the server are not supported yet.
722 function html() {
723 var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
724 wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
725 startCloseTagPos, endOpenTagPos, endCloseTagPos,
726 result = null;
727
728 // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
729 // 1. open through closeHtmlTag
730 // 2. expression
731 // 3. openHtmlEnd through close
732 // This will allow recording the positions to reconstruct if HTML is to be treated as text.
733
734 startOpenTagPos = pos;
735 parsedOpenTagResult = sequence( [
736 openHtmlStartTag,
737 asciiAlphabetLiteral,
738 htmlAttributes,
739 optionalForwardSlash,
740 closeHtmlTag
741 ] );
742
743 if ( parsedOpenTagResult === null ) {
744 return null;
745 }
746
747 endOpenTagPos = pos;
748 startTagName = parsedOpenTagResult[ 1 ];
749
750 parsedHtmlContents = nOrMore( 0, expression )();
751
752 startCloseTagPos = pos;
753 parsedCloseTagResult = sequence( [
754 openHtmlEndTag,
755 asciiAlphabetLiteral,
756 closeHtmlTag
757 ] );
758
759 if ( parsedCloseTagResult === null ) {
760 // Closing tag failed. Return the start tag and contents.
761 return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
762 .concat( parsedHtmlContents );
763 }
764
765 endCloseTagPos = pos;
766 endTagName = parsedCloseTagResult[ 1 ];
767 wrappedAttributes = parsedOpenTagResult[ 2 ];
768 attributes = wrappedAttributes.slice( 1 );
769 if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
770 result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
771 .concat( parsedHtmlContents );
772 } else {
773 // HTML is not allowed, so contents will remain how
774 // it was, while HTML markup at this level will be
775 // treated as text
776 // E.g. assuming script tags are not allowed:
777 //
778 // <script>[[Foo|bar]]</script>
779 //
780 // results in '&lt;script&gt;' and '&lt;/script&gt;'
781 // (not treated as an HTML tag), surrounding a fully
782 // parsed HTML link.
783 //
784 // Concatenate everything from the tag, flattening the contents.
785 result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
786 .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
787 }
788
789 return result;
790 }
791
792 // <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
793 function nowiki() {
794 var parsedResult, plainText,
795 result = null;
796
797 parsedResult = sequence( [
798 makeStringParser( '<nowiki>' ),
799 // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
800 makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
801 makeStringParser( '</nowiki>' )
802 ] );
803 if ( parsedResult !== null ) {
804 plainText = parsedResult[ 1 ];
805 result = [ 'CONCAT' ].concat( plainText );
806 }
807
808 return result;
809 }
810
811 templateName = transform(
812 // see $wgLegalTitleChars
813 // not allowing : due to the need to catch "PLURAL:$1"
814 makeRegexParser( /^[ !"$&'()*,.\/0-9;=?@A-Z\^_`a-z~\x80-\xFF+\-]+/ ),
815 function ( result ) { return result.toString(); }
816 );
817 function templateParam() {
818 var expr, result;
819 result = sequence( [
820 pipe,
821 nOrMore( 0, paramExpression )
822 ] );
823 if ( result === null ) {
824 return null;
825 }
826 expr = result[ 1 ];
827 // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
828 return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
829 }
830
831 function templateWithReplacement() {
832 var result = sequence( [
833 templateName,
834 colon,
835 replacement
836 ] );
837 return result === null ? null : [ result[ 0 ], result[ 2 ] ];
838 }
839 function templateWithOutReplacement() {
840 var result = sequence( [
841 templateName,
842 colon,
843 paramExpression
844 ] );
845 return result === null ? null : [ result[ 0 ], result[ 2 ] ];
846 }
847 function templateWithOutFirstParameter() {
848 var result = sequence( [
849 templateName,
850 colon
851 ] );
852 return result === null ? null : [ result[ 0 ], '' ];
853 }
854 colon = makeStringParser( ':' );
855 templateContents = choice( [
856 function () {
857 var res = sequence( [
858 // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
859 // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
860 choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
861 nOrMore( 0, templateParam )
862 ] );
863 return res === null ? null : res[ 0 ].concat( res[ 1 ] );
864 },
865 function () {
866 var res = sequence( [
867 templateName,
868 nOrMore( 0, templateParam )
869 ] );
870 if ( res === null ) {
871 return null;
872 }
873 return [ res[ 0 ] ].concat( res[ 1 ] );
874 }
875 ] );
876 openTemplate = makeStringParser( '{{' );
877 closeTemplate = makeStringParser( '}}' );
878 nonWhitespaceExpression = choice( [
879 template,
880 wikilink,
881 extlink,
882 replacement,
883 literalWithoutSpace
884 ] );
885 paramExpression = choice( [
886 template,
887 wikilink,
888 extlink,
889 replacement,
890 literalWithoutBar
891 ] );
892
893 expression = choice( [
894 template,
895 wikilink,
896 extlink,
897 replacement,
898 nowiki,
899 html,
900 literal
901 ] );
902
903 // Used when only {{-transformation is wanted, for 'text'
904 // or 'escaped' formats
905 curlyBraceTransformExpression = choice( [
906 template,
907 replacement,
908 curlyBraceTransformExpressionLiteral
909 ] );
910
911 /**
912 * Starts the parse
913 *
914 * @param {Function} rootExpression Root parse function
915 * @return {Array|null}
916 */
917 function start( rootExpression ) {
918 var result = nOrMore( 0, rootExpression )();
919 if ( result === null ) {
920 return null;
921 }
922 return [ 'CONCAT' ].concat( result );
923 }
924 // everything above this point is supposed to be stateless/static, but
925 // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
926 // finally let's do some actual work...
927
928 result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
929
930 /*
931 * For success, the p must have gotten to the end of the input
932 * and returned a non-null.
933 * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
934 */
935 if ( result === null || pos !== input.length ) {
936 throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
937 }
938 return result;
939 }
940
941 };
942
943 /**
944 * htmlEmitter - object which primarily exists to emit HTML from parser ASTs
945 *
946 * @param {Object} language
947 * @param {Object} magic
948 */
949 mw.jqueryMsg.htmlEmitter = function ( language, magic ) {
950 var jmsg = this;
951 this.language = language;
952 $.each( magic, function ( key, val ) {
953 jmsg[ key.toLowerCase() ] = function () {
954 return val;
955 };
956 } );
957
958 /**
959 * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
960 * Walk entire node structure, applying replacements and template functions when appropriate
961 *
962 * @param {Mixed} node Abstract syntax tree (top node or subnode)
963 * @param {Array} replacements for $1, $2, ... $n
964 * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
965 */
966 this.emit = function ( node, replacements ) {
967 var ret, subnodes, operation,
968 jmsg = this;
969 switch ( typeof node ) {
970 case 'string':
971 case 'number':
972 ret = node;
973 break;
974 // typeof returns object for arrays
975 case 'object':
976 // node is an array of nodes
977 subnodes = $.map( node.slice( 1 ), function ( n ) {
978 return jmsg.emit( n, replacements );
979 } );
980 operation = node[ 0 ].toLowerCase();
981 if ( typeof jmsg[ operation ] === 'function' ) {
982 ret = jmsg[ operation ]( subnodes, replacements );
983 } else {
984 throw new Error( 'Unknown operation "' + operation + '"' );
985 }
986 break;
987 case 'undefined':
988 // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
989 // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
990 // The logical thing is probably to return the empty string here when we encounter undefined.
991 ret = '';
992 break;
993 default:
994 throw new Error( 'Unexpected type in AST: ' + typeof node );
995 }
996 return ret;
997 };
998 };
999
1000 // For everything in input that follows double-open-curly braces, there should be an equivalent parser
1001 // function. For instance {{PLURAL ... }} will be processed by 'plural'.
1002 // If you have 'magic words' then configure the parser to have them upon creation.
1003 //
1004 // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
1005 // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
1006 mw.jqueryMsg.htmlEmitter.prototype = {
1007 /**
1008 * Parsing has been applied depth-first we can assume that all nodes here are single nodes
1009 * Must return a single node to parents -- a jQuery with synthetic span
1010 * However, unwrap any other synthetic spans in our children and pass them upwards
1011 *
1012 * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
1013 * @return {jQuery}
1014 */
1015 concat: function ( nodes ) {
1016 var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
1017 $.each( nodes, function ( i, node ) {
1018 // Let jQuery append nodes, arrays of nodes and jQuery objects
1019 // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
1020 appendWithoutParsing( $span, node );
1021 } );
1022 return $span;
1023 },
1024
1025 /**
1026 * Return escaped replacement of correct index, or string if unavailable.
1027 * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
1028 * if the specified parameter is not found return the same string
1029 * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
1030 *
1031 * TODO: Throw error if nodes.length > 1 ?
1032 *
1033 * @param {Array} nodes List of one element, integer, n >= 0
1034 * @param {Array} replacements List of at least n strings
1035 * @return {string} replacement
1036 */
1037 replace: function ( nodes, replacements ) {
1038 var index = parseInt( nodes[ 0 ], 10 );
1039
1040 if ( index < replacements.length ) {
1041 return replacements[ index ];
1042 } else {
1043 // index not found, fallback to displaying variable
1044 return '$' + ( index + 1 );
1045 }
1046 },
1047
1048 /**
1049 * Transform wiki-link
1050 *
1051 * TODO:
1052 * It only handles basic cases, either no pipe, or a pipe with an explicit
1053 * anchor.
1054 *
1055 * It does not attempt to handle features like the pipe trick.
1056 * However, the pipe trick should usually not be present in wikitext retrieved
1057 * from the server, since the replacement is done at save time.
1058 * It may, though, if the wikitext appears in extension-controlled content.
1059 *
1060 * @param {string[]} nodes
1061 * @return {jQuery}
1062 */
1063 wikilink: function ( nodes ) {
1064 var page, anchor, url, $el;
1065
1066 page = textify( nodes[ 0 ] );
1067 // Strip leading ':', which is used to suppress special behavior in wikitext links,
1068 // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
1069 if ( page.charAt( 0 ) === ':' ) {
1070 page = page.slice( 1 );
1071 }
1072 url = mw.util.getUrl( page );
1073
1074 if ( nodes.length === 1 ) {
1075 // [[Some Page]] or [[Namespace:Some Page]]
1076 anchor = page;
1077 } else {
1078 // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
1079 anchor = nodes[ 1 ];
1080 }
1081
1082 $el = $( '<a>' ).attr( {
1083 title: page,
1084 href: url
1085 } );
1086 return appendWithoutParsing( $el, anchor );
1087 },
1088
1089 /**
1090 * Converts array of HTML element key value pairs to object
1091 *
1092 * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
1093 * name and 2 * n + 1 the associated value
1094 * @return {Object} Object mapping attribute name to attribute value
1095 */
1096 htmlattributes: function ( nodes ) {
1097 var i, len, mapping = {};
1098 for ( i = 0, len = nodes.length; i < len; i += 2 ) {
1099 mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
1100 }
1101 return mapping;
1102 },
1103
1104 /**
1105 * Handles an (already-validated) HTML element.
1106 *
1107 * @param {Array} nodes Nodes to process when creating element
1108 * @return {jQuery|Array} jQuery node for valid HTML or array for disallowed element
1109 */
1110 htmlelement: function ( nodes ) {
1111 var tagName, attributes, contents, $element;
1112
1113 tagName = nodes.shift();
1114 attributes = nodes.shift();
1115 contents = nodes;
1116 $element = $( document.createElement( tagName ) ).attr( attributes );
1117 return appendWithoutParsing( $element, contents );
1118 },
1119
1120 /**
1121 * Transform parsed structure into external link.
1122 *
1123 * The "href" can be:
1124 * - a jQuery object, treat it as "enclosing" the link text.
1125 * - a function, treat it as the click handler.
1126 * - a string, or our htmlEmitter jQuery object, treat it as a URI after stringifying.
1127 *
1128 * TODO: throw an error if nodes.length > 2 ?
1129 *
1130 * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
1131 * @return {jQuery}
1132 */
1133 extlink: function ( nodes ) {
1134 var $el,
1135 arg = nodes[ 0 ],
1136 contents = nodes[ 1 ];
1137 if ( arg instanceof jQuery && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
1138 $el = arg;
1139 } else {
1140 $el = $( '<a>' );
1141 if ( typeof arg === 'function' ) {
1142 $el.attr( {
1143 role: 'button',
1144 tabindex: 0
1145 } )
1146 .on( 'click keypress', function ( e ) {
1147 if (
1148 e.type === 'click' ||
1149 e.type === 'keypress' && e.which === 13
1150 ) {
1151 arg.call( this, e );
1152 }
1153 } );
1154 } else {
1155 $el.attr( 'href', textify( arg ) );
1156 }
1157 }
1158 return appendWithoutParsing( $el.empty(), contents );
1159 },
1160
1161 /**
1162 * Transform parsed structure into pluralization
1163 * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
1164 * So convert it back with the current language's convertNumber.
1165 *
1166 * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
1167 * @return {string} selected pluralized form according to current language
1168 */
1169 plural: function ( nodes ) {
1170 var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
1171 explicitPluralForms = {};
1172
1173 count = parseFloat( this.language.convertNumber( nodes[ 0 ], true ) );
1174 forms = nodes.slice( 1 );
1175 for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
1176 form = forms[ formIndex ];
1177
1178 if ( form instanceof jQuery && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
1179 // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
1180 firstChild = form.contents().get( 0 );
1181 if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
1182 firstChildText = firstChild.textContent;
1183 if ( /^\d+=/.test( firstChildText ) ) {
1184 explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
1185 // Use the digit part as key and rest of first text node and
1186 // rest of child nodes as value.
1187 firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
1188 explicitPluralForms[ explicitPluralFormNumber ] = form;
1189 forms[ formIndex ] = undefined;
1190 }
1191 }
1192 } else if ( /^\d+=/.test( form ) ) {
1193 // Simple explicit plural forms like 12=a dozen
1194 explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
1195 explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
1196 forms[ formIndex ] = undefined;
1197 }
1198 }
1199
1200 // Remove explicit plural forms from the forms. They were set undefined in the above loop.
1201 forms = $.map( forms, function ( form ) {
1202 return form;
1203 } );
1204
1205 return this.language.convertPlural( count, forms, explicitPluralForms );
1206 },
1207
1208 /**
1209 * Transform parsed structure according to gender.
1210 *
1211 * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
1212 *
1213 * The first node must be one of:
1214 * - the mw.user object (or a compatible one)
1215 * - an empty string - indicating the current user, same effect as passing the mw.user object
1216 * - a gender string ('male', 'female' or 'unknown')
1217 *
1218 * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
1219 * @return {string} Selected gender form according to current language
1220 */
1221 gender: function ( nodes ) {
1222 var gender,
1223 maybeUser = nodes[ 0 ],
1224 forms = nodes.slice( 1 );
1225
1226 if ( maybeUser === '' ) {
1227 maybeUser = mw.user;
1228 }
1229
1230 // If we are passed a mw.user-like object, check their gender.
1231 // Otherwise, assume the gender string itself was passed .
1232 if ( maybeUser && maybeUser.options instanceof mw.Map ) {
1233 gender = maybeUser.options.get( 'gender' );
1234 } else {
1235 gender = maybeUser;
1236 }
1237
1238 return this.language.gender( gender, forms );
1239 },
1240
1241 /**
1242 * Transform parsed structure into grammar conversion.
1243 * Invoked by putting `{{grammar:form|word}}` in a message
1244 *
1245 * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
1246 * @return {string} selected grammatical form according to current language
1247 */
1248 grammar: function ( nodes ) {
1249 var form = nodes[ 0 ],
1250 word = nodes[ 1 ];
1251 return word && form && this.language.convertGrammar( word, form );
1252 },
1253
1254 /**
1255 * Tranform parsed structure into a int: (interface language) message include
1256 * Invoked by putting `{{int:othermessage}}` into a message
1257 *
1258 * @param {Array} nodes List of nodes
1259 * @return {string} Other message
1260 */
1261 'int': function ( nodes ) {
1262 var msg = nodes[ 0 ];
1263 return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
1264 },
1265
1266 /**
1267 * Get localized namespace name from canonical name or namespace number.
1268 * Invoked by putting `{{ns:foo}}` into a message
1269 *
1270 * @param {Array} nodes List of nodes
1271 * @return {string} Localized namespace name
1272 */
1273 ns: function ( nodes ) {
1274 var ns = $.trim( textify( nodes[ 0 ] ) );
1275 if ( !/^\d+$/.test( ns ) ) {
1276 ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
1277 }
1278 ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
1279 return ns || '';
1280 },
1281
1282 /**
1283 * Takes an unformatted number (arab, no group separators and . as decimal separator)
1284 * and outputs it in the localized digit script and formatted with decimal
1285 * separator, according to the current language.
1286 *
1287 * @param {Array} nodes List of nodes
1288 * @return {number|string} Formatted number
1289 */
1290 formatnum: function ( nodes ) {
1291 var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
1292 number = nodes[ 0 ];
1293
1294 return this.language.convertNumber( number, isInteger );
1295 },
1296
1297 /**
1298 * Lowercase text
1299 *
1300 * @param {Array} nodes List of nodes
1301 * @return {string} The given text, all in lowercase
1302 */
1303 lc: function ( nodes ) {
1304 return textify( nodes[ 0 ] ).toLowerCase();
1305 },
1306
1307 /**
1308 * Uppercase text
1309 *
1310 * @param {Array} nodes List of nodes
1311 * @return {string} The given text, all in uppercase
1312 */
1313 uc: function ( nodes ) {
1314 return textify( nodes[ 0 ] ).toUpperCase();
1315 },
1316
1317 /**
1318 * Lowercase first letter of input, leaving the rest unchanged
1319 *
1320 * @param {Array} nodes List of nodes
1321 * @return {string} The given text, with the first character in lowercase
1322 */
1323 lcfirst: function ( nodes ) {
1324 var text = textify( nodes[ 0 ] );
1325 return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
1326 },
1327
1328 /**
1329 * Uppercase first letter of input, leaving the rest unchanged
1330 *
1331 * @param {Array} nodes List of nodes
1332 * @return {string} The given text, with the first character in uppercase
1333 */
1334 ucfirst: function ( nodes ) {
1335 var text = textify( nodes[ 0 ] );
1336 return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
1337 }
1338 };
1339
1340 // Deprecated! don't rely on gM existing.
1341 // The window.gM ought not to be required - or if required, not required here.
1342 // But moving it to extensions breaks it (?!)
1343 // Need to fix plugin so it could do attributes as well, then will be okay to remove this.
1344 // @deprecated since 1.23
1345 mw.log.deprecate( window, 'gM', mw.jqueryMsg.getMessageFunction(), 'Use mw.message( ... ).parse() instead.' );
1346
1347 /**
1348 * @method
1349 * @member jQuery
1350 * @see mw.jqueryMsg#getPlugin
1351 */
1352 $.fn.msg = mw.jqueryMsg.getPlugin();
1353
1354 // Replace the default message parser with jqueryMsg
1355 oldParser = mw.Message.prototype.parser;
1356 mw.Message.prototype.parser = function () {
1357 if ( this.format === 'plain' || !/\{\{|[\[<>&]/.test( this.map.get( this.key ) ) ) {
1358 // Fall back to mw.msg's simple parser
1359 return oldParser.apply( this );
1360 }
1361
1362 if ( !this.map.hasOwnProperty( this.format ) ) {
1363 this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
1364 messages: this.map,
1365 // For format 'escaped', escaping part is handled by mediawiki.js
1366 format: this.format
1367 } );
1368 }
1369 return this.map[ this.format ]( this.key, this.parameters );
1370 };
1371
1372 /**
1373 * Parse the message to DOM nodes, rather than HTML string like #parse.
1374 *
1375 * This method is only available when jqueryMsg is loaded.
1376 *
1377 * @since 1.27
1378 * @method parseDom
1379 * @member mw.Message
1380 * @return {jQuery}
1381 */
1382 mw.Message.prototype.parseDom = ( function () {
1383 var reusableParent = $( '<div>' );
1384 return function () {
1385 return reusableParent.msg( this.key, this.parameters ).contents().detach();
1386 };
1387 }() );
1388
1389 }( mediaWiki, jQuery ) );