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