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