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