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