2 * Experimental advanced wikitext parser-emitter.
3 * See: http://www.mediawiki.org/wiki/Extension:UploadWizard/MessageParser for docs
5 * @author neilk@wikimedia.org
6 * @author mflaschen@wikimedia.org
10 slice = Array.prototype.slice,
13 'SITENAME' : mw.config.get( 'wgSiteName' )
15 // This is a whitelist based on, but simpler than, Sanitizer.php.
16 // Self-closing tags are not currently supported.
17 allowedHtmlElements : [
21 // Key tag name, value allowed attributes for that tag.
22 // See Sanitizer::setupAttributeWhitelist
23 allowedHtmlCommonAttributes : [
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,
43 // Same meaning as in mediawiki.js.
45 // Only 'text', 'parse', and 'escaped' are supported, and the
46 // actual escaping for 'escaped' is done by other code (generally
47 // through jqueryMsg).
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.
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.
60 * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
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
66 function appendWithoutParsing( $parent, children ) {
69 if ( !$.isArray( children ) ) {
70 children = [children];
73 for ( i = 0, len = children.length; i < len; i++ ) {
74 if ( typeof children[i] !== 'object' ) {
75 children[i] = document.createTextNode( children[i] );
79 return $parent.append( children );
83 * Decodes the main HTML entities, those encoded by mw.html.escape.
85 * @param {string} encode Encoded string
86 * @return {string} String with those entities decoded
88 function decodePrimaryHtmlEntities( encoded ) {
90 .replace( /'/g, '\'' )
91 .replace( /"/g, '"' )
92 .replace( /</g, '<' )
93 .replace( />/g, '>' )
94 .replace( /&/g, '&' );
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}
102 function getFailableParserFn( options ) {
103 var parser = new mw.jqueryMsg.parser( options );
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.
109 * @param {Array} first element is the key, replacements may be in array in 2nd element, or remaining elements.
112 return function ( args ) {
114 argsArray = $.isArray( args[1] ) ? args[1] : slice.call( args, 1 );
116 return parser.parse( key, argsArray );
118 return $( '<span>' ).text( key + ': ' + e.message );
127 * Returns a function suitable for use as a global, to construct strings from the message key (and optional replacements).
129 * window.gM = mediaWiki.parser.getMessageFunction( options );
130 * $( 'p#headline' ).html( gM( 'hello-user', username ) );
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().
135 * @param {Array} parser options
136 * @return {Function} function suitable for assigning to window.gM
138 mw.jqueryMsg.getMessageFunction = function ( options ) {
139 var failableParserFn = getFailableParserFn( options ),
142 if ( options && options.format !== undefined ) {
143 format = options.format;
145 format = parserDefaults.format;
149 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
150 * somefunction(a, b, c, d)
152 * somefunction(a, [b, c, d])
154 * @param {string} key Message key.
155 * @param {Array|mixed} replacements Optional variable replacements (variadically or an array).
156 * @return {string} Rendered HTML.
159 var failableResult = failableParserFn( arguments );
160 if ( format === 'text' || format === 'escaped' ) {
161 return failableResult.text();
163 return failableResult.html();
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.
173 * $.fn.msg = mediaWiki.parser.getJqueryPlugin( options );
174 * var userlink = $( '<a>' ).click( function () { alert( "hello!!") } );
175 * $( 'p#headline' ).msg( 'hello-user', userlink );
177 * @param {Array} parser options
178 * @return {Function} function suitable for assigning to jQuery plugin, such as $.fn.msg
180 mw.jqueryMsg.getPlugin = function ( options ) {
181 var failableParserFn = getFailableParserFn( options );
183 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
184 * somefunction(a, b, c, d)
186 * somefunction(a, [b, c, d])
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
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 );
206 * Describes an object, whose primary duty is to .parse() message keys.
207 * @param {Array} options
209 mw.jqueryMsg.parser = function ( options ) {
210 this.settings = $.extend( {}, parserDefaults, options );
211 this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
213 this.emitter = new mw.jqueryMsg.htmlEmitter( this.settings.language, this.settings.magic );
216 mw.jqueryMsg.parser.prototype = {
218 * Cache mapping MediaWiki message keys and the value onlyCurlyBraceTransform, to the AST of the message.
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).
223 * The two parts of the key are separated by colon. For example:
225 * "message-key:true": ast
227 * if they key is "message-key" and onlyCurlyBraceTransform is true.
229 * This cache is shared by all instances of mw.jqueryMsg.parser.
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
243 parse: function ( key, replacements ) {
244 return this.emitter.emit( this.getAst( key ), replacements );
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
252 getAst: function ( key ) {
253 var cacheKey = [key, this.settings.onlyCurlyBraceTransform].join( ':' ), wikiText;
255 if ( this.astCache[ cacheKey ] === undefined ) {
256 wikiText = this.settings.messages.get( key );
257 if ( typeof wikiText !== 'string' ) {
258 wikiText = '\\[' + key + '\\]';
260 this.astCache[ cacheKey ] = this.wikiTextToAst( wikiText );
262 return this.astCache[ cacheKey ];
266 * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
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.
271 * @param {String} message string wikitext
273 * @return {Mixed} abstract syntax tree
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;
286 // Indicates current position in input as we parse through it.
287 // Shared among all parsing functions below.
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 ) {
297 for ( i = 0; i < ps.length; i++ ) {
299 if ( result !== null ) {
306 // try several ps in a row, all must succeed or return null
307 // this is the only eager one
308 function sequence( ps ) {
312 for ( i = 0; i < ps.length; i++ ) {
314 if ( res === null ) {
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 ) {
326 var originalPos = pos,
329 while ( parsed !== null ) {
330 result.push( parsed );
333 if ( result.length < n ) {
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 ) {
346 return result === null ? null : fn( result );
349 // Helpers -- just make ps out of simpler JS builtin types
350 function makeStringParser( s ) {
354 if ( input.substr( pos, len ) === s ) {
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
367 * @param {RegExp} regex anchored regex
368 * @return {Function} function to parse input based on the regex
370 function makeRegexParser( regex ) {
372 var matches = input.substr( pos ).match( regex );
373 if ( matches === null ) {
376 pos += matches[0].length;
382 * ===================================================================
383 * General patterns above this line -- wikitext specific parsers below
384 * ===================================================================
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
397 regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
398 regularLiteralWithoutBar = makeRegexParser(/^[^{}\[\]$\\|]/);
399 regularLiteralWithoutSpace = makeRegexParser(/^[^{}\[\]$\s]/);
400 regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
402 backslash = makeStringParser( '\\' );
403 doubleQuote = makeStringParser( '"' );
404 singleQuote = makeStringParser( '\'' );
405 anyCharacter = makeRegexParser( /^./ );
407 openHtmlStartTag = makeStringParser( '<' );
408 optionalForwardSlash = makeRegexParser( /^\/?/ );
409 openHtmlEndTag = makeStringParser( '</' );
410 htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
411 closeHtmlTag = makeRegexParser( /^\s*>/ );
413 function escapedLiteral() {
414 var result = sequence( [
418 return result === null ? null : result[1];
420 escapedOrLiteralWithoutSpace = choice( [
422 regularLiteralWithoutSpace
424 escapedOrLiteralWithoutBar = choice( [
426 regularLiteralWithoutBar
428 escapedOrRegularLiteral = choice( [
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('');
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('');
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('');
452 var result = nOrMore( 1, escapedOrRegularLiteral )();
453 return result === null ? null : result.join('');
456 function curlyBraceTransformExpressionLiteral() {
457 var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
458 return result === null ? null : result.join('');
461 asciiAlphabetLiteral = makeRegexParser( /[A-Za-z]+/ );
462 htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
463 htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
465 whitespace = makeRegexParser( /^\s+/ );
466 dollar = makeStringParser( '$' );
467 digits = makeRegexParser( /^\d+/ );
469 function replacement() {
470 var result = sequence( [
474 if ( result === null ) {
477 return [ 'REPLACE', parseInt( result[1], 10 ) - 1 ];
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
483 var result, parsedResult;
485 parsedResult = sequence( [
487 nonWhitespaceExpression,
489 nOrMore( 1, expression ),
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] );
499 result.push( ['CONCAT'].concat( parsedResult[3] ) );
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( [
514 if ( result === null ) {
517 return [ 'EXTLINKPARAM', parseInt( result[2], 10 ) - 1, result[4] ];
519 openWikilink = makeStringParser( '[[' );
520 closeWikilink = makeStringParser( ']]' );
521 pipe = makeStringParser( '|' );
523 function template() {
524 var result = sequence( [
529 return result === null ? null : result[1];
532 wikilinkPage = choice( [
533 unescapedLiteralWithoutBar,
537 function pipedWikilink() {
538 var result = sequence( [
543 return result === null ? null : [ result[0], result[2] ];
546 wikilinkContents = choice( [
548 wikilinkPage // unpiped link
551 function wikilink() {
552 var result, parsedResult, parsedLinkContents;
555 parsedResult = sequence( [
560 if ( parsedResult !== null ) {
561 parsedLinkContents = parsedResult[1];
562 result = [ 'WIKILINK' ].concat( parsedLinkContents );
567 // TODO: Support data- if appropriate
568 function doubleQuotedHtmlAttributeValue() {
569 var parsedResult = sequence( [
571 htmlDoubleQuoteAttributeValue,
574 return parsedResult === null ? null : parsedResult[1];
577 function singleQuotedHtmlAttributeValue() {
578 var parsedResult = sequence( [
580 htmlSingleQuoteAttributeValue,
583 return parsedResult === null ? null : parsedResult[1];
586 function htmlAttribute() {
587 var parsedResult = sequence( [
589 asciiAlphabetLiteral,
592 doubleQuotedHtmlAttributeValue,
593 singleQuotedHtmlAttributeValue
596 return parsedResult === null ? null : [parsedResult[1], parsedResult[3]];
600 * Checks if HTML is allowed
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
608 function isAllowedHtml( startTagName, endTagName, attributes ) {
609 var i, len, attributeName;
611 startTagName = startTagName.toLowerCase();
612 endTagName = endTagName.toLowerCase();
613 if ( startTagName !== endTagName || $.inArray( startTagName, settings.allowedHtmlElements ) === -1 ) {
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 ) {
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 );
634 // Subset of allowed HTML markup.
635 // Most elements and many attributes allowed on the server are not supported yet.
637 var result = null, parsedOpenTagResult, parsedHtmlContents,
638 parsedCloseTagResult, wrappedAttributes, attributes,
639 startTagName, endTagName, startOpenTagPos, startCloseTagPos,
640 endOpenTagPos, endCloseTagPos;
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
645 // 3. openHtmlEnd through close
646 // This will allow recording the positions to reconstruct if HTML is to be treated as text.
648 startOpenTagPos = pos;
649 parsedOpenTagResult = sequence( [
651 asciiAlphabetLiteral,
653 optionalForwardSlash,
657 if ( parsedOpenTagResult === null ) {
662 startTagName = parsedOpenTagResult[1];
664 parsedHtmlContents = nOrMore( 0, expression )();
666 startCloseTagPos = pos;
667 parsedCloseTagResult = sequence( [
669 asciiAlphabetLiteral,
673 if ( parsedCloseTagResult === null ) {
674 // Closing tag failed. Return the start tag and contents.
675 return [ 'CONCAT', input.substring( startOpenTagPos, endOpenTagPos ) ].concat( parsedHtmlContents );
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 );
685 // HTML is not allowed, so contents will remain how
686 // it was, while HTML markup at this level will be
688 // E.g. assuming script tags are not allowed:
690 // <script>[[Foo|bar]]</script>
692 // results in '<script>' and '</script>'
693 // (not treated as an HTML tag), surrounding a fully
696 // Concatenate everything from the tag, flattening the contents.
697 result = [ 'CONCAT', input.substring( startOpenTagPos, endOpenTagPos ) ].concat( parsedHtmlContents, input.substring( startCloseTagPos, endCloseTagPos ) );
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(); }
709 function templateParam() {
713 nOrMore( 0, paramExpression )
715 if ( result === null ) {
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];
723 function templateWithReplacement() {
724 var result = sequence( [
729 return result === null ? null : [ result[0], result[2] ];
731 function templateWithOutReplacement() {
732 var result = sequence( [
737 return result === null ? null : [ result[0], result[2] ];
739 colon = makeStringParser(':');
740 templateContents = choice( [
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 )
748 return res === null ? null : res[0].concat( res[1] );
751 var res = sequence( [
753 nOrMore( 0, templateParam )
755 if ( res === null ) {
758 return [ res[0] ].concat( res[1] );
761 openTemplate = makeStringParser('{{');
762 closeTemplate = makeStringParser('}}');
763 nonWhitespaceExpression = choice( [
771 paramExpression = choice( [
780 expression = choice( [
790 // Used when only {{-transformation is wanted, for 'text'
791 // or 'escaped' formats
792 curlyBraceTransformExpression = choice( [
795 curlyBraceTransformExpressionLiteral
802 * @param {Function} rootExpression root parse function
804 function start( rootExpression ) {
805 var result = nOrMore( 0, rootExpression )();
806 if ( result === null ) {
809 return [ 'CONCAT' ].concat( result );
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...
815 // If you add another possible rootExpression, you must update the astCache key scheme.
816 result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
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.
823 if ( result === null || pos !== input.length ) {
824 throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
831 * htmlEmitter - object which primarily exists to emit HTML from parser ASTs
833 mw.jqueryMsg.htmlEmitter = function ( language, magic ) {
834 this.language = language;
836 $.each( magic, function ( key, val ) {
837 jmsg[ key.toLowerCase() ] = function () {
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
848 this.emit = function ( node, replacements ) {
849 var ret, subnodes, operation,
851 switch ( typeof node ) {
856 // typeof returns object for arrays
858 // node is an array of nodes
859 subnodes = $.map( node.slice( 1 ), function ( n ) {
860 return jmsg.emit( n, replacements );
862 operation = node[0].toLowerCase();
863 if ( typeof jmsg[operation] === 'function' ) {
864 ret = jmsg[ operation ]( subnodes, replacements );
866 throw new Error( 'Unknown operation "' + operation + '"' );
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.
876 throw new Error( 'Unexpected type in AST: ' + typeof node );
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.
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 = {
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
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 );
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 );
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
920 replace: function ( nodes, replacements ) {
921 var index = parseInt( nodes[0], 10 );
923 if ( index < replacements.length ) {
924 return replacements[index];
926 // index not found, fallback to displaying variable
927 return '$' + ( index + 1 );
932 * Transform wiki-link
935 * It only handles basic cases, either no pipe, or a pipe with an explicit
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.
945 wikilink: function ( nodes ) {
946 var page, anchor, url;
949 url = mw.util.wikiGetlink( page );
951 // [[Some Page]] or [[Namespace:Some Page]]
952 if ( nodes.length === 1 ) {
957 * [[Some Page|anchor text]] or
958 * [[Namespace:Some Page|anchor]
964 return $( '<a />' ).attr( {
971 * Converts array of HTML element key value pairs to object
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
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] );
985 * Handles an (already-validated) HTML element.
987 * @param {Array} nodes nodes to process when creating element
988 * @return {jQuery|Array} jQuery node for valid HTML or array for disallowed element
990 htmlelement: function ( nodes ) {
991 var tagName, attributes, contents, $element;
993 tagName = nodes.shift();
994 attributes = nodes.shift();
996 $element = $( document.createElement( tagName ) ).attr( attributes );
997 return appendWithoutParsing( $element, contents );
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}
1009 extlink: function ( nodes ) {
1012 contents = nodes[1];
1013 if ( arg instanceof jQuery ) {
1017 if ( typeof arg === 'function' ) {
1018 $el.click( arg ).attr( 'href', '#' );
1020 $el.attr( 'href', arg.toString() );
1023 return appendWithoutParsing( $el, contents );
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
1035 extlinkparam: function ( nodes, replacements ) {
1037 index = parseInt( nodes[0], 10 );
1038 if ( index < replacements.length) {
1039 replacement = replacements[index];
1041 replacement = '$' + ( index + 1 );
1043 return this.extlink( [ replacement, nodes[1] ] );
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
1053 plural: function ( nodes ) {
1055 count = parseFloat( this.language.convertNumber( nodes[0], true ) );
1056 forms = nodes.slice(1);
1057 return forms.length ? this.language.convertPlural( count, forms ) : '';
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).
1066 * @param {Array} of nodes, [ {String|mw.User}, {String}, {String}, {String} ]
1067 * @return {String} selected gender form according to current language
1069 gender: function ( nodes ) {
1072 if ( nodes[0] && nodes[0].options instanceof mw.Map ) {
1073 gender = nodes[0].options.get( 'gender' );
1078 forms = nodes.slice( 1 );
1080 return this.language.gender( gender, forms );
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
1089 grammar: function ( nodes ) {
1090 var form = nodes[0],
1092 return word && form && this.language.convertGrammar( word, form );
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
1101 int: function ( nodes ) {
1102 return mw.jqueryMsg.getMessageFunction()( nodes[0].toLowerCase() );
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
1112 formatnum: function ( nodes ) {
1113 var isInteger = ( nodes[1] && nodes[1] === 'R' ) ? true : false,
1116 return this.language.convertNumber( number, isInteger );
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();
1126 // Replace the default message parser with jqueryMsg
1127 oldParser = mw.Message.prototype.parser;
1128 mw.Message.prototype.parser = function () {
1129 var messageFunction;
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 );
1140 messageFunction = mw.jqueryMsg.getMessageFunction( {
1141 'messages': this.map,
1142 // For format 'escaped', escaping part is handled by mediawiki.js
1143 'format': this.format
1145 return messageFunction( this.key, this.parameters );
1148 }( mediaWiki, jQuery ) );