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