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