2 * Experimental advanced wikitext parser-emitter.
3 * See: https://www.mediawiki.org/wiki/Extension:UploadWizard/MessageParser for docs
5 * @author neilk@wikimedia.org
6 * @author mflaschen@wikimedia.org
15 slice = Array.prototype.slice,
18 'SITENAME': mw.config.get( 'wgSiteName' )
20 // This is a whitelist based on, but simpler than, Sanitizer.php.
21 // Self-closing tags are not currently supported.
22 allowedHtmlElements: [
26 // Key tag name, value allowed attributes for that tag.
27 // See Sanitizer::setupAttributeWhitelist
28 allowedHtmlCommonAttributes: [
41 // Attributes allowed for specific elements.
42 // Key is element name in lower case
43 // Value is array of allowed attributes for that element
44 allowedHtmlAttributesByElement: {},
45 messages: mw.messages,
46 language: mw.language,
48 // Same meaning as in mediawiki.js.
50 // Only 'text', 'parse', and 'escaped' are supported, and the
51 // actual escaping for 'escaped' is done by other code (generally
52 // through mediawiki.js).
54 // However, note that this default only
55 // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
56 // is 'text', including when it uses jqueryMsg.
62 * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
63 * convert what it detects as an htmlString to an element.
65 * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
68 * @param {jQuery} $parent Parent node wrapped by jQuery
69 * @param {Object|string|Array} children What to append, with the same possible types as jQuery
70 * @return {jQuery} $parent
72 function appendWithoutParsing( $parent, children ) {
75 if ( !$.isArray( children ) ) {
76 children = [children];
79 for ( i = 0, len = children.length; i < len; i++ ) {
80 if ( typeof children[i] !== 'object' ) {
81 children[i] = document.createTextNode( children[i] );
85 return $parent.append( children );
89 * Decodes the main HTML entities, those encoded by mw.html.escape.
92 * @param {string} encoded Encoded string
93 * @return {string} String with those entities decoded
95 function decodePrimaryHtmlEntities( encoded ) {
97 .replace( /'/g, '\'' )
98 .replace( /"/g, '"' )
99 .replace( /</g, '<' )
100 .replace( />/g, '>' )
101 .replace( /&/g, '&' );
105 * Given parser options, return a function that parses a key and replacements, returning jQuery object
107 * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
108 * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
109 * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
111 * @param {Object} options Parser options
113 * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
114 * @return {jQuery} return.return
116 function getFailableParserFn( options ) {
117 var parser = new mw.jqueryMsg.parser( options );
119 return function ( args ) {
122 argsArray = $.isArray( args[1] ) ? args[1] : slice.call( args, 1 );
124 return parser.parse( key, argsArray );
126 fallback = parser.settings.messages.get( key );
127 mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
128 return $( '<span>' ).text( fallback );
136 * Returns a function suitable for use as a global, to construct strings from the message key (and optional replacements).
139 * window.gM = mediaWiki.parser.getMessageFunction( options );
140 * $( 'p#headline' ).html( gM( 'hello-user', username ) );
142 * Like the old gM() function this returns only strings, so it destroys any bindings. If you want to preserve bindings use the
143 * jQuery plugin version instead. This is only included for backwards compatibility with gM().
145 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
146 * somefunction( a, b, c, d )
148 * somefunction( a, [b, c, d] )
150 * @param {Object} options parser options
151 * @return {Function} Function suitable for assigning to window.gM
152 * @return {string} return.key Message key.
153 * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
154 * @return {string} return.return Rendered HTML.
156 mw.jqueryMsg.getMessageFunction = function ( options ) {
157 var failableParserFn = getFailableParserFn( options ),
160 if ( options && options.format !== undefined ) {
161 format = options.format;
163 format = parserDefaults.format;
167 var failableResult = failableParserFn( arguments );
168 if ( format === 'text' || format === 'escaped' ) {
169 return failableResult.text();
171 return failableResult.html();
177 * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
178 * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
181 * $.fn.msg = mediaWiki.parser.getJqueryPlugin( options );
182 * var userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
183 * $( 'p#headline' ).msg( 'hello-user', userlink );
185 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
186 * somefunction( a, b, c, d )
188 * somefunction( a, [b, c, d] )
190 * We append to 'this', which in a jQuery plugin context will be the selected elements.
192 * @param {Object} options Parser options
193 * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
194 * @return {string} return.key Message key.
195 * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
196 * @return {jQuery} return.return
198 mw.jqueryMsg.getPlugin = function ( options ) {
199 var failableParserFn = getFailableParserFn( options );
202 var $target = this.empty();
203 // TODO: Simply appendWithoutParsing( $target, failableParserFn( arguments ).contents() )
204 // or Simply appendWithoutParsing( $target, failableParserFn( arguments ) )
205 $.each( failableParserFn( arguments ).contents(), function ( i, node ) {
206 appendWithoutParsing( $target, node );
214 * Describes an object, whose primary duty is to .parse() message keys.
218 * @param {Object} options
220 mw.jqueryMsg.parser = function ( options ) {
221 this.settings = $.extend( {}, parserDefaults, options );
222 this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
224 this.emitter = new mw.jqueryMsg.htmlEmitter( this.settings.language, this.settings.magic );
227 mw.jqueryMsg.parser.prototype = {
229 * Cache mapping MediaWiki message keys and the value onlyCurlyBraceTransform, to the AST of the message.
231 * In most cases, the message is a string so this is identical.
232 * (This is why we would like to move this functionality server-side).
234 * The two parts of the key are separated by colon. For example:
236 * "message-key:true": ast
238 * if they key is "message-key" and onlyCurlyBraceTransform is true.
240 * This cache is shared by all instances of mw.jqueryMsg.parser.
242 * NOTE: We promise, it's static - when you create this empty object
243 * in the prototype, each new instance of the class gets a reference
244 * to the same object.
252 * Where the magic happens.
253 * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
254 * If an error is thrown, returns original key, and logs the error
255 * @param {string} key Message key.
256 * @param {Array} replacements Variable replacements for $1, $2... $n
259 parse: function ( key, replacements ) {
260 return this.emitter.emit( this.getAst( key ), replacements );
264 * Fetch the message string associated with a key, return parsed structure. Memoized.
265 * Note that we pass '[' + key + ']' back for a missing message here.
266 * @param {string} key
267 * @return {string|Array} string of '[key]' if message missing, simple string if possible, array of arrays if needs parsing
269 getAst: function ( key ) {
270 var cacheKey = [key, this.settings.onlyCurlyBraceTransform].join( ':' ), wikiText;
272 if ( this.astCache[ cacheKey ] === undefined ) {
273 wikiText = this.settings.messages.get( key );
274 if ( typeof wikiText !== 'string' ) {
275 wikiText = '\\[' + key + '\\]';
277 this.astCache[ cacheKey ] = this.wikiTextToAst( wikiText );
279 return this.astCache[ cacheKey ];
283 * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
285 * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
286 * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
288 * @param {string} input Message string wikitext
290 * @return {Mixed} abstract syntax tree
292 wikiTextToAst: function ( input ) {
293 var pos, settings = this.settings, concat = Array.prototype.concat,
294 regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
295 doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
296 escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
297 whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
298 htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
299 openExtlink, closeExtlink, wikilinkPage, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
300 templateContents, openTemplate, closeTemplate,
301 nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result;
303 // Indicates current position in input as we parse through it.
304 // Shared among all parsing functions below.
307 // =========================================================
308 // parsing combinators - could be a library on its own
309 // =========================================================
312 * Try parsers until one works, if none work return null
314 * @param {Function[]} ps
315 * @return {string|null}
317 function choice( ps ) {
320 for ( i = 0; i < ps.length; i++ ) {
322 if ( result !== null ) {
331 * Try several ps in a row, all must succeed or return null.
332 * This is the only eager one.
334 * @param {Function[]} ps
335 * @return {string|null}
337 function sequence( ps ) {
341 for ( i = 0; i < ps.length; i++ ) {
343 if ( res === null ) {
353 * Run the same parser over and over until it fails.
354 * Must succeed a minimum of n times or return null.
357 * @param {Function} p
358 * @return {string|null}
360 function nOrMore( n, p ) {
362 var originalPos = pos,
365 while ( parsed !== null ) {
366 result.push( parsed );
369 if ( result.length < n ) {
378 * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
380 * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
381 * May be some scoping issue
384 * @param {Function} p
385 * @param {Function} fn
386 * @return {string|null}
388 function transform( p, fn ) {
391 return result === null ? null : fn( result );
396 * Just make parsers out of simpler JS builtin types
400 * @return {string} return.return
402 function makeStringParser( s ) {
406 if ( input.substr( pos, len ) === s ) {
415 * Makes a regex parser, given a RegExp object.
416 * The regex being passed in should start with a ^ to anchor it to the start
420 * @param {RegExp} regex anchored regex
421 * @return {Function} function to parse input based on the regex
423 function makeRegexParser( regex ) {
425 var matches = input.slice( pos ).match( regex );
426 if ( matches === null ) {
429 pos += matches[0].length;
434 // ===================================================================
435 // General patterns above this line -- wikitext specific parsers below
436 // ===================================================================
438 // Parsing functions follow. All parsing functions work like this:
439 // They don't accept any arguments.
440 // Instead, they just operate non destructively on the string 'input'
441 // As they can consume parts of the string, they advance the shared variable pos,
442 // and return tokens (or whatever else they want to return).
443 // some things are defined as closures and other things as ordinary functions
444 // converting everything to a closure makes it a lot harder to debug... errors pop up
445 // but some debuggers can't tell you exactly where they come from. Also the mutually
446 // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
447 // This may be because, to save code, memoization was removed
449 regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
450 regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
451 regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
452 regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
454 backslash = makeStringParser( '\\' );
455 doubleQuote = makeStringParser( '"' );
456 singleQuote = makeStringParser( '\'' );
457 anyCharacter = makeRegexParser( /^./ );
459 openHtmlStartTag = makeStringParser( '<' );
460 optionalForwardSlash = makeRegexParser( /^\/?/ );
461 openHtmlEndTag = makeStringParser( '</' );
462 htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
463 closeHtmlTag = makeRegexParser( /^\s*>/ );
465 function escapedLiteral() {
466 var result = sequence( [
470 return result === null ? null : result[1];
472 escapedOrLiteralWithoutSpace = choice( [
474 regularLiteralWithoutSpace
476 escapedOrLiteralWithoutBar = choice( [
478 regularLiteralWithoutBar
480 escapedOrRegularLiteral = choice( [
484 // Used to define "literals" without spaces, in space-delimited situations
485 function literalWithoutSpace() {
486 var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
487 return result === null ? null : result.join( '' );
489 // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
490 // it is not a literal in the parameter
491 function literalWithoutBar() {
492 var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
493 return result === null ? null : result.join( '' );
496 // Used for wikilink page names. Like literalWithoutBar, but
497 // without allowing escapes.
498 function unescapedLiteralWithoutBar() {
499 var result = nOrMore( 1, regularLiteralWithoutBar )();
500 return result === null ? null : result.join( '' );
504 var result = nOrMore( 1, escapedOrRegularLiteral )();
505 return result === null ? null : result.join( '' );
508 function curlyBraceTransformExpressionLiteral() {
509 var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
510 return result === null ? null : result.join( '' );
513 asciiAlphabetLiteral = makeRegexParser( /[A-Za-z]+/ );
514 htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
515 htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
517 whitespace = makeRegexParser( /^\s+/ );
518 dollar = makeStringParser( '$' );
519 digits = makeRegexParser( /^\d+/ );
521 function replacement() {
522 var result = sequence( [
526 if ( result === null ) {
529 return [ 'REPLACE', parseInt( result[1], 10 ) - 1 ];
531 openExtlink = makeStringParser( '[' );
532 closeExtlink = makeStringParser( ']' );
533 // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
535 var result, parsedResult;
537 parsedResult = sequence( [
539 nonWhitespaceExpression,
541 nOrMore( 1, expression ),
544 if ( parsedResult !== null ) {
545 result = [ 'EXTLINK', parsedResult[1] ];
546 // TODO (mattflaschen, 2013-03-22): Clean this up if possible.
547 // It's avoiding CONCAT for single nodes, so they at least doesn't get the htmlEmitter span.
548 if ( parsedResult[3].length === 1 ) {
549 result.push( parsedResult[3][0] );
551 result.push( ['CONCAT'].concat( parsedResult[3] ) );
556 // this is the same as the above extlink, except that the url is being passed on as a parameter
557 function extLinkParam() {
558 var result = sequence( [
566 if ( result === null ) {
569 return [ 'EXTLINKPARAM', parseInt( result[2], 10 ) - 1, result[4] ];
571 openWikilink = makeStringParser( '[[' );
572 closeWikilink = makeStringParser( ']]' );
573 pipe = makeStringParser( '|' );
575 function template() {
576 var result = sequence( [
581 return result === null ? null : result[1];
584 wikilinkPage = choice( [
585 unescapedLiteralWithoutBar,
589 function pipedWikilink() {
590 var result = sequence( [
595 return result === null ? null : [ result[0], result[2] ];
598 wikilinkContents = choice( [
600 wikilinkPage // unpiped link
603 function wikilink() {
604 var result, parsedResult, parsedLinkContents;
607 parsedResult = sequence( [
612 if ( parsedResult !== null ) {
613 parsedLinkContents = parsedResult[1];
614 result = [ 'WIKILINK' ].concat( parsedLinkContents );
619 // TODO: Support data- if appropriate
620 function doubleQuotedHtmlAttributeValue() {
621 var parsedResult = sequence( [
623 htmlDoubleQuoteAttributeValue,
626 return parsedResult === null ? null : parsedResult[1];
629 function singleQuotedHtmlAttributeValue() {
630 var parsedResult = sequence( [
632 htmlSingleQuoteAttributeValue,
635 return parsedResult === null ? null : parsedResult[1];
638 function htmlAttribute() {
639 var parsedResult = sequence( [
641 asciiAlphabetLiteral,
644 doubleQuotedHtmlAttributeValue,
645 singleQuotedHtmlAttributeValue
648 return parsedResult === null ? null : [parsedResult[1], parsedResult[3]];
652 * Checks if HTML is allowed
654 * @param {string} startTagName HTML start tag name
655 * @param {string} endTagName HTML start tag name
656 * @param {Object} attributes array of consecutive key value pairs,
657 * with index 2 * n being a name and 2 * n + 1 the associated value
658 * @return {boolean} true if this is HTML is allowed, false otherwise
660 function isAllowedHtml( startTagName, endTagName, attributes ) {
661 var i, len, attributeName;
663 startTagName = startTagName.toLowerCase();
664 endTagName = endTagName.toLowerCase();
665 if ( startTagName !== endTagName || $.inArray( startTagName, settings.allowedHtmlElements ) === -1 ) {
669 for ( i = 0, len = attributes.length; i < len; i += 2 ) {
670 attributeName = attributes[i];
671 if ( $.inArray( attributeName, settings.allowedHtmlCommonAttributes ) === -1 &&
672 $.inArray( attributeName, settings.allowedHtmlAttributesByElement[startTagName] || [] ) === -1 ) {
680 function htmlAttributes() {
681 var parsedResult = nOrMore( 0, htmlAttribute )();
682 // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
683 return concat.apply( ['HTMLATTRIBUTES'], parsedResult );
686 // Subset of allowed HTML markup.
687 // Most elements and many attributes allowed on the server are not supported yet.
689 var result = null, parsedOpenTagResult, parsedHtmlContents,
690 parsedCloseTagResult, wrappedAttributes, attributes,
691 startTagName, endTagName, startOpenTagPos, startCloseTagPos,
692 endOpenTagPos, endCloseTagPos;
694 // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
695 // 1. open through closeHtmlTag
697 // 3. openHtmlEnd through close
698 // This will allow recording the positions to reconstruct if HTML is to be treated as text.
700 startOpenTagPos = pos;
701 parsedOpenTagResult = sequence( [
703 asciiAlphabetLiteral,
705 optionalForwardSlash,
709 if ( parsedOpenTagResult === null ) {
714 startTagName = parsedOpenTagResult[1];
716 parsedHtmlContents = nOrMore( 0, expression )();
718 startCloseTagPos = pos;
719 parsedCloseTagResult = sequence( [
721 asciiAlphabetLiteral,
725 if ( parsedCloseTagResult === null ) {
726 // Closing tag failed. Return the start tag and contents.
727 return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
728 .concat( parsedHtmlContents );
731 endCloseTagPos = pos;
732 endTagName = parsedCloseTagResult[1];
733 wrappedAttributes = parsedOpenTagResult[2];
734 attributes = wrappedAttributes.slice( 1 );
735 if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
736 result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
737 .concat( parsedHtmlContents );
739 // HTML is not allowed, so contents will remain how
740 // it was, while HTML markup at this level will be
742 // E.g. assuming script tags are not allowed:
744 // <script>[[Foo|bar]]</script>
746 // results in '<script>' and '</script>'
747 // (not treated as an HTML tag), surrounding a fully
750 // Concatenate everything from the tag, flattening the contents.
751 result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
752 .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
758 templateName = transform(
759 // see $wgLegalTitleChars
760 // not allowing : due to the need to catch "PLURAL:$1"
761 makeRegexParser( /^[ !"$&'()*,.\/0-9;=?@A-Z\^_`a-z~\x80-\xFF+\-]+/ ),
762 function ( result ) { return result.toString(); }
764 function templateParam() {
768 nOrMore( 0, paramExpression )
770 if ( result === null ) {
774 // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
775 return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[0];
778 function templateWithReplacement() {
779 var result = sequence( [
784 return result === null ? null : [ result[0], result[2] ];
786 function templateWithOutReplacement() {
787 var result = sequence( [
792 return result === null ? null : [ result[0], result[2] ];
794 function templateWithOutFirstParameter() {
795 var result = sequence( [
799 return result === null ? null : [ result[0], '' ];
801 colon = makeStringParser( ':' );
802 templateContents = choice( [
804 var res = sequence( [
805 // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
806 // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
807 choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
808 nOrMore( 0, templateParam )
810 return res === null ? null : res[0].concat( res[1] );
813 var res = sequence( [
815 nOrMore( 0, templateParam )
817 if ( res === null ) {
820 return [ res[0] ].concat( res[1] );
823 openTemplate = makeStringParser( '{{' );
824 closeTemplate = makeStringParser( '}}' );
825 nonWhitespaceExpression = choice( [
833 paramExpression = choice( [
842 expression = choice( [
852 // Used when only {{-transformation is wanted, for 'text'
853 // or 'escaped' formats
854 curlyBraceTransformExpression = choice( [
857 curlyBraceTransformExpressionLiteral
863 * @param {Function} rootExpression root parse function
865 function start( rootExpression ) {
866 var result = nOrMore( 0, rootExpression )();
867 if ( result === null ) {
870 return [ 'CONCAT' ].concat( result );
872 // everything above this point is supposed to be stateless/static, but
873 // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
874 // finally let's do some actual work...
876 // If you add another possible rootExpression, you must update the astCache key scheme.
877 result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
880 * For success, the p must have gotten to the end of the input
881 * and returned a non-null.
882 * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
884 if ( result === null || pos !== input.length ) {
885 throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
893 * htmlEmitter - object which primarily exists to emit HTML from parser ASTs
895 mw.jqueryMsg.htmlEmitter = function ( language, magic ) {
896 this.language = language;
898 $.each( magic, function ( key, val ) {
899 jmsg[ key.toLowerCase() ] = function () {
905 * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
906 * Walk entire node structure, applying replacements and template functions when appropriate
907 * @param {Mixed} node Abstract syntax tree (top node or subnode)
908 * @param {Array} replacements for $1, $2, ... $n
909 * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
911 this.emit = function ( node, replacements ) {
912 var ret, subnodes, operation,
914 switch ( typeof node ) {
919 // typeof returns object for arrays
921 // node is an array of nodes
922 subnodes = $.map( node.slice( 1 ), function ( n ) {
923 return jmsg.emit( n, replacements );
925 operation = node[0].toLowerCase();
926 if ( typeof jmsg[operation] === 'function' ) {
927 ret = jmsg[ operation ]( subnodes, replacements );
929 throw new Error( 'Unknown operation "' + operation + '"' );
933 // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
934 // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
935 // The logical thing is probably to return the empty string here when we encounter undefined.
939 throw new Error( 'Unexpected type in AST: ' + typeof node );
945 // For everything in input that follows double-open-curly braces, there should be an equivalent parser
946 // function. For instance {{PLURAL ... }} will be processed by 'plural'.
947 // If you have 'magic words' then configure the parser to have them upon creation.
949 // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
950 // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
951 mw.jqueryMsg.htmlEmitter.prototype = {
953 * Parsing has been applied depth-first we can assume that all nodes here are single nodes
954 * Must return a single node to parents -- a jQuery with synthetic span
955 * However, unwrap any other synthetic spans in our children and pass them upwards
956 * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
959 concat: function ( nodes ) {
960 var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
961 $.each( nodes, function ( i, node ) {
962 if ( node instanceof jQuery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) {
963 $.each( node.contents(), function ( j, childNode ) {
964 appendWithoutParsing( $span, childNode );
967 // Let jQuery append nodes, arrays of nodes and jQuery objects
968 // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
969 appendWithoutParsing( $span, node );
976 * Return escaped replacement of correct index, or string if unavailable.
977 * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
978 * if the specified parameter is not found return the same string
979 * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
981 * TODO: Throw error if nodes.length > 1 ?
983 * @param {Array} nodes List of one element, integer, n >= 0
984 * @param {Array} replacements List of at least n strings
985 * @return {String} replacement
987 replace: function ( nodes, replacements ) {
988 var index = parseInt( nodes[0], 10 );
990 if ( index < replacements.length ) {
991 return replacements[index];
993 // index not found, fallback to displaying variable
994 return '$' + ( index + 1 );
999 * Transform wiki-link
1002 * It only handles basic cases, either no pipe, or a pipe with an explicit
1005 * It does not attempt to handle features like the pipe trick.
1006 * However, the pipe trick should usually not be present in wikitext retrieved
1007 * from the server, since the replacement is done at save time.
1008 * It may, though, if the wikitext appears in extension-controlled content.
1012 wikilink: function ( nodes ) {
1013 var page, anchor, url;
1016 url = mw.util.getUrl( page );
1018 // [[Some Page]] or [[Namespace:Some Page]]
1019 if ( nodes.length === 1 ) {
1024 * [[Some Page|anchor text]] or
1025 * [[Namespace:Some Page|anchor]
1031 return $( '<a>' ).attr( {
1038 * Converts array of HTML element key value pairs to object
1040 * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
1041 * name and 2 * n + 1 the associated value
1042 * @return {Object} Object mapping attribute name to attribute value
1044 htmlattributes: function ( nodes ) {
1045 var i, len, mapping = {};
1046 for ( i = 0, len = nodes.length; i < len; i += 2 ) {
1047 mapping[nodes[i]] = decodePrimaryHtmlEntities( nodes[i + 1] );
1053 * Handles an (already-validated) HTML element.
1055 * @param {Array} nodes Nodes to process when creating element
1056 * @return {jQuery|Array} jQuery node for valid HTML or array for disallowed element
1058 htmlelement: function ( nodes ) {
1059 var tagName, attributes, contents, $element;
1061 tagName = nodes.shift();
1062 attributes = nodes.shift();
1064 $element = $( document.createElement( tagName ) ).attr( attributes );
1065 return appendWithoutParsing( $element, contents );
1069 * Transform parsed structure into external link
1070 * If the href is a jQuery object, treat it as "enclosing" the link text.
1072 * - ... function, treat it as the click handler.
1073 * - ... string, treat it as a URI.
1075 * TODO: throw an error if nodes.length > 2 ?
1077 * @param {Array} nodes List of two elements, {jQuery|Function|String} and {String}
1080 extlink: function ( nodes ) {
1083 contents = nodes[1];
1084 if ( arg instanceof jQuery ) {
1088 if ( typeof arg === 'function' ) {
1089 $el.attr( 'href', '#' )
1090 .click( function ( e ) {
1095 $el.attr( 'href', arg.toString() );
1098 return appendWithoutParsing( $el, contents );
1102 * This is basically use a combination of replace + external link (link with parameter
1103 * as url), but we don't want to run the regular replace here-on: inserting a
1104 * url as href-attribute of a link will automatically escape it already, so
1105 * we don't want replace to (manually) escape it as well.
1107 * TODO: throw error if nodes.length > 1 ?
1109 * @param {Array} nodes List of one element, integer, n >= 0
1110 * @param {Array} replacements List of at least n strings
1111 * @return {string} replacement
1113 extlinkparam: function ( nodes, replacements ) {
1115 index = parseInt( nodes[0], 10 );
1116 if ( index < replacements.length ) {
1117 replacement = replacements[index];
1119 replacement = '$' + ( index + 1 );
1121 return this.extlink( [ replacement, nodes[1] ] );
1125 * Transform parsed structure into pluralization
1126 * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
1127 * So convert it back with the current language's convertNumber.
1128 * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
1129 * @return {string} selected pluralized form according to current language
1131 plural: function ( nodes ) {
1132 var forms, firstChild, firstChildText,
1133 explicitPluralForms = {}, explicitPluralFormNumber, formIndex, form, count;
1135 count = parseFloat( this.language.convertNumber( nodes[0], true ) );
1136 forms = nodes.slice( 1 );
1137 for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
1138 form = forms[formIndex];
1140 if ( form.jquery && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
1141 // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
1142 firstChild = form.contents().get( 0 );
1143 if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
1144 firstChildText = firstChild.textContent;
1145 if ( /^\d+=/.test( firstChildText ) ) {
1146 explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[0], 10 );
1147 // Use the digit part as key and rest of first text node and
1148 // rest of child nodes as value.
1149 firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
1150 explicitPluralForms[explicitPluralFormNumber] = form;
1151 forms[formIndex] = undefined;
1154 } else if ( /^\d+=/.test( form ) ) {
1155 // Simple explicit plural forms like 12=a dozen
1156 explicitPluralFormNumber = parseInt( form.split( /=/ )[0], 10 );
1157 explicitPluralForms[explicitPluralFormNumber] = form.slice( form.indexOf( '=' ) + 1 );
1158 forms[formIndex] = undefined;
1162 // Remove explicit plural forms from the forms. They were set undefined in the above loop.
1163 forms = $.map( forms, function ( form ) {
1167 return this.language.convertPlural( count, forms, explicitPluralForms );
1171 * Transform parsed structure according to gender.
1173 * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
1175 * The first node must be one of:
1176 * - the mw.user object (or a compatible one)
1177 * - an empty string - indicating the current user, same effect as passing the mw.user object
1178 * - a gender string ('male', 'female' or 'unknown')
1180 * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
1181 * @return {string} Selected gender form according to current language
1183 gender: function ( nodes ) {
1185 maybeUser = nodes[0],
1186 forms = nodes.slice( 1 );
1188 if ( maybeUser === '' ) {
1189 maybeUser = mw.user;
1192 // If we are passed a mw.user-like object, check their gender.
1193 // Otherwise, assume the gender string itself was passed .
1194 if ( maybeUser && maybeUser.options instanceof mw.Map ) {
1195 gender = maybeUser.options.get( 'gender' );
1200 return this.language.gender( gender, forms );
1204 * Transform parsed structure into grammar conversion.
1205 * Invoked by putting `{{grammar:form|word}}` in a message
1206 * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
1207 * @return {string} selected grammatical form according to current language
1209 grammar: function ( nodes ) {
1210 var form = nodes[0],
1212 return word && form && this.language.convertGrammar( word, form );
1216 * Tranform parsed structure into a int: (interface language) message include
1217 * Invoked by putting `{{int:othermessage}}` into a message
1218 * @param {Array} nodes List of nodes
1219 * @return {string} Other message
1221 'int': function ( nodes ) {
1222 return mw.jqueryMsg.getMessageFunction()( nodes[0].toLowerCase() );
1226 * Takes an unformatted number (arab, no group separators and . as decimal separator)
1227 * and outputs it in the localized digit script and formatted with decimal
1228 * separator, according to the current language.
1229 * @param {Array} nodes List of nodes
1230 * @return {number|string} Formatted number
1232 formatnum: function ( nodes ) {
1233 var isInteger = ( nodes[1] && nodes[1] === 'R' ) ? true : false,
1236 return this.language.convertNumber( number, isInteger );
1240 // Deprecated! don't rely on gM existing.
1241 // The window.gM ought not to be required - or if required, not required here.
1242 // But moving it to extensions breaks it (?!)
1243 // Need to fix plugin so it could do attributes as well, then will be okay to remove this.
1244 // @deprecated since 1.23
1245 mw.log.deprecate( window, 'gM', mw.jqueryMsg.getMessageFunction(), 'Use mw.message( ... ).parse() instead.' );
1250 * @see mw.jqueryMsg#getPlugin
1252 $.fn.msg = mw.jqueryMsg.getPlugin();
1254 // Replace the default message parser with jqueryMsg
1255 oldParser = mw.Message.prototype.parser;
1256 mw.Message.prototype.parser = function () {
1257 var messageFunction;
1259 // TODO: should we cache the message function so we don't create a new one every time? Benchmark this maybe?
1260 // Caching is somewhat problematic, because we do need different message functions for different maps, so
1261 // we'd have to cache the parser as a member of this.map, which sounds a bit ugly.
1262 // Do not use mw.jqueryMsg unless required
1263 if ( this.format === 'plain' || !/\{\{|[\[<>]/.test( this.map.get( this.key ) ) ) {
1264 // Fall back to mw.msg's simple parser
1265 return oldParser.apply( this );
1268 messageFunction = mw.jqueryMsg.getMessageFunction( {
1269 'messages': this.map,
1270 // For format 'escaped', escaping part is handled by mediawiki.js
1271 'format': this.format
1273 return messageFunction( this.key, this.parameters );
1276 }( mediaWiki, jQuery ) );