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 // Whitelist for allowed HTML elements in wikitext.
21 // Self-closing tags are not currently supported.
22 // Can be populated via setPrivateData().
23 allowedHtmlElements: [],
24 // Key tag name, value allowed attributes for that tag.
25 // See Sanitizer::setupAttributeWhitelist
26 allowedHtmlCommonAttributes: [
39 // Attributes allowed for specific elements.
40 // Key is element name in lower case
41 // Value is array of allowed attributes for that element
42 allowedHtmlAttributesByElement: {},
43 messages: mw.messages,
44 language: mw.language,
46 // Same meaning as in mediawiki.js.
48 // Only 'text', 'parse', and 'escaped' are supported, and the
49 // actual escaping for 'escaped' is done by other code (generally
50 // through mediawiki.js).
52 // However, note that this default only
53 // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
54 // is 'text', including when it uses jqueryMsg.
59 * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
60 * convert what it detects as an htmlString to an element.
62 * If our own htmlEmitter jQuery object is given, its children will be unwrapped and appended to
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 ] );
83 if ( children[ i ] instanceof jQuery && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
84 children[ i ] = children[ i ].contents();
88 return $parent.append( children );
92 * Decodes the main HTML entities, those encoded by mw.html.escape.
95 * @param {string} encoded Encoded string
96 * @return {string} String with those entities decoded
98 function decodePrimaryHtmlEntities( encoded ) {
100 .replace( /'/g, '\'' )
101 .replace( /"/g, '"' )
102 .replace( /</g, '<' )
103 .replace( />/g, '>' )
104 .replace( /&/g, '&' );
108 * Turn input into a string.
111 * @param {string|jQuery} input
112 * @return {string} Textual value of input
114 function textify( input ) {
115 if ( input instanceof jQuery ) {
116 input = input.text();
118 return String( input );
122 * Given parser options, return a function that parses a key and replacements, returning jQuery object
124 * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
125 * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
126 * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
129 * @param {Object} options Parser options
131 * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
132 * @return {jQuery} return.return
134 function getFailableParserFn( options ) {
135 return function ( args ) {
137 parser = new mw.jqueryMsg.parser( options ),
139 argsArray = $.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
141 return parser.parse( key, argsArray );
143 fallback = parser.settings.messages.get( key );
144 mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
145 return $( '<span>' ).text( fallback );
153 * Initialize parser defaults.
155 * ResourceLoaderJqueryMsgModule calls this to provide default values from
156 * Sanitizer.php for allowed HTML elements. To override this data for individual
157 * parsers, pass the relevant options to mw.jqueryMsg.parser.
160 * @param {Object} data
162 mw.jqueryMsg.setParserDefaults = function ( data ) {
163 $.extend( parserDefaults, data );
167 * Get current parser defaults.
169 * Primarily used for the unit test. Returns a copy.
174 mw.jqueryMsg.getParserDefaults = function () {
175 return $.extend( {}, parserDefaults );
179 * Returns a function suitable for use as a global, to construct strings from the message key (and optional replacements).
182 * window.gM = mediaWiki.jqueryMsg.getMessageFunction( options );
183 * $( 'p#headline' ).html( gM( 'hello-user', username ) );
185 * Like the old gM() function this returns only strings, so it destroys any bindings. If you want to preserve bindings use the
186 * jQuery plugin version instead. This is only included for backwards compatibility with gM().
188 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
189 * somefunction( a, b, c, d )
191 * somefunction( a, [b, c, d] )
193 * @param {Object} options parser options
194 * @return {Function} Function suitable for assigning to window.gM
195 * @return {string} return.key Message key.
196 * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
197 * @return {string} return.return Rendered HTML.
199 mw.jqueryMsg.getMessageFunction = function ( options ) {
200 var failableParserFn, format;
202 if ( options && options.format !== undefined ) {
203 format = options.format;
205 format = parserDefaults.format;
209 if ( !failableParserFn ) {
210 failableParserFn = getFailableParserFn( options );
212 var failableResult = failableParserFn( arguments );
213 if ( format === 'text' || format === 'escaped' ) {
214 return failableResult.text();
216 return failableResult.html();
222 * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
223 * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
226 * $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
227 * var userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
228 * $( 'p#headline' ).msg( 'hello-user', userlink );
230 * N.B. replacements are variadic arguments or an array in second parameter. In other words:
231 * somefunction( a, b, c, d )
233 * somefunction( a, [b, c, d] )
235 * We append to 'this', which in a jQuery plugin context will be the selected elements.
237 * @param {Object} options Parser options
238 * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
239 * @return {string} return.key Message key.
240 * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
241 * @return {jQuery} return.return
243 mw.jqueryMsg.getPlugin = function ( options ) {
244 var failableParserFn;
247 if ( !failableParserFn ) {
248 failableParserFn = getFailableParserFn( options );
250 var $target = this.empty();
251 appendWithoutParsing( $target, failableParserFn( arguments ) );
258 * Describes an object, whose primary duty is to .parse() message keys.
262 * @param {Object} options
264 mw.jqueryMsg.parser = function ( options ) {
265 this.settings = $.extend( {}, parserDefaults, options );
266 this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
269 this.emitter = new mw.jqueryMsg.htmlEmitter( this.settings.language, this.settings.magic );
272 mw.jqueryMsg.parser.prototype = {
274 * Where the magic happens.
275 * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
276 * If an error is thrown, returns original key, and logs the error
278 * @param {string} key Message key.
279 * @param {Array} replacements Variable replacements for $1, $2... $n
282 parse: function ( key, replacements ) {
283 var ast = this.getAst( key );
284 return this.emitter.emit( ast, replacements );
288 * Fetch the message string associated with a key, return parsed structure. Memoized.
289 * Note that we pass '[' + key + ']' back for a missing message here.
291 * @param {string} key
292 * @return {string|Array} string of '[key]' if message missing, simple string if possible, array of arrays if needs parsing
294 getAst: function ( key ) {
297 if ( !this.astCache.hasOwnProperty( key ) ) {
298 wikiText = this.settings.messages.get( key );
299 if ( typeof wikiText !== 'string' ) {
300 wikiText = '\\[' + key + '\\]';
302 this.astCache[ key ] = this.wikiTextToAst( wikiText );
304 return this.astCache[ key ];
308 * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
310 * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
311 * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
313 * @param {string} input Message string wikitext
315 * @return {Mixed} abstract syntax tree
317 wikiTextToAst: function ( input ) {
319 regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
320 doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
321 escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
322 whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
323 htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
324 openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
325 templateContents, openTemplate, closeTemplate,
326 nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
327 settings = this.settings,
328 concat = Array.prototype.concat;
330 // Indicates current position in input as we parse through it.
331 // Shared among all parsing functions below.
334 // =========================================================
335 // parsing combinators - could be a library on its own
336 // =========================================================
339 * Try parsers until one works, if none work return null
342 * @param {Function[]} ps
343 * @return {string|null}
345 function choice( ps ) {
348 for ( i = 0; i < ps.length; i++ ) {
350 if ( result !== null ) {
359 * Try several ps in a row, all must succeed or return null.
360 * This is the only eager one.
363 * @param {Function[]} ps
364 * @return {string|null}
366 function sequence( ps ) {
370 for ( i = 0; i < ps.length; i++ ) {
372 if ( res === null ) {
382 * Run the same parser over and over until it fails.
383 * Must succeed a minimum of n times or return null.
387 * @param {Function} p
388 * @return {string|null}
390 function nOrMore( n, p ) {
392 var originalPos = pos,
395 while ( parsed !== null ) {
396 result.push( parsed );
399 if ( result.length < n ) {
408 * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
410 * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
411 * May be some scoping issue
414 * @param {Function} p
415 * @param {Function} fn
416 * @return {string|null}
418 function transform( p, fn ) {
421 return result === null ? null : fn( result );
426 * Just make parsers out of simpler JS builtin types
431 * @return {string} return.return
433 function makeStringParser( s ) {
437 if ( input.substr( pos, len ) === s ) {
446 * Makes a regex parser, given a RegExp object.
447 * The regex being passed in should start with a ^ to anchor it to the start
451 * @param {RegExp} regex anchored regex
452 * @return {Function} function to parse input based on the regex
454 function makeRegexParser( regex ) {
456 var matches = input.slice( pos ).match( regex );
457 if ( matches === null ) {
460 pos += matches[ 0 ].length;
465 // ===================================================================
466 // General patterns above this line -- wikitext specific parsers below
467 // ===================================================================
469 // Parsing functions follow. All parsing functions work like this:
470 // They don't accept any arguments.
471 // Instead, they just operate non destructively on the string 'input'
472 // As they can consume parts of the string, they advance the shared variable pos,
473 // and return tokens (or whatever else they want to return).
474 // some things are defined as closures and other things as ordinary functions
475 // converting everything to a closure makes it a lot harder to debug... errors pop up
476 // but some debuggers can't tell you exactly where they come from. Also the mutually
477 // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
478 // This may be because, to save code, memoization was removed
480 regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
481 regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
482 regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
483 regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
485 backslash = makeStringParser( '\\' );
486 doubleQuote = makeStringParser( '"' );
487 singleQuote = makeStringParser( '\'' );
488 anyCharacter = makeRegexParser( /^./ );
490 openHtmlStartTag = makeStringParser( '<' );
491 optionalForwardSlash = makeRegexParser( /^\/?/ );
492 openHtmlEndTag = makeStringParser( '</' );
493 htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
494 closeHtmlTag = makeRegexParser( /^\s*>/ );
496 function escapedLiteral() {
497 var result = sequence( [
501 return result === null ? null : result[ 1 ];
503 escapedOrLiteralWithoutSpace = choice( [
505 regularLiteralWithoutSpace
507 escapedOrLiteralWithoutBar = choice( [
509 regularLiteralWithoutBar
511 escapedOrRegularLiteral = choice( [
515 // Used to define "literals" without spaces, in space-delimited situations
516 function literalWithoutSpace() {
517 var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
518 return result === null ? null : result.join( '' );
520 // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
521 // it is not a literal in the parameter
522 function literalWithoutBar() {
523 var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
524 return result === null ? null : result.join( '' );
528 var result = nOrMore( 1, escapedOrRegularLiteral )();
529 return result === null ? null : result.join( '' );
532 function curlyBraceTransformExpressionLiteral() {
533 var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
534 return result === null ? null : result.join( '' );
537 asciiAlphabetLiteral = makeRegexParser( /[A-Za-z]+/ );
538 htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
539 htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
541 whitespace = makeRegexParser( /^\s+/ );
542 dollar = makeStringParser( '$' );
543 digits = makeRegexParser( /^\d+/ );
545 function replacement() {
546 var result = sequence( [
550 if ( result === null ) {
553 return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
555 openExtlink = makeStringParser( '[' );
556 closeExtlink = makeStringParser( ']' );
557 // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
559 var result, parsedResult, target;
561 parsedResult = sequence( [
563 nOrMore( 1, nonWhitespaceExpression ),
565 nOrMore( 1, expression ),
568 if ( parsedResult !== null ) {
569 // When the entire link target is a single parameter, we can't use CONCAT, as we allow
570 // passing fancy parameters (like a whole jQuery object or a function) to use for the
571 // link. Check only if it's a single match, since we can either do CONCAT or not for
572 // singles with the same effect.
573 target = parsedResult[ 1 ].length === 1 ?
574 parsedResult[ 1 ][ 0 ] :
575 [ 'CONCAT' ].concat( parsedResult[ 1 ] );
579 [ 'CONCAT' ].concat( parsedResult[ 3 ] )
584 openWikilink = makeStringParser( '[[' );
585 closeWikilink = makeStringParser( ']]' );
586 pipe = makeStringParser( '|' );
588 function template() {
589 var result = sequence( [
594 return result === null ? null : result[ 1 ];
597 function pipedWikilink() {
598 var result = sequence( [
599 nOrMore( 1, paramExpression ),
601 nOrMore( 1, expression )
603 return result === null ? null : [
604 [ 'CONCAT' ].concat( result[ 0 ] ),
605 [ 'CONCAT' ].concat( result[ 2 ] )
609 function unpipedWikilink() {
610 var result = sequence( [
611 nOrMore( 1, paramExpression )
613 return result === null ? null : [
614 [ 'CONCAT' ].concat( result[ 0 ] )
618 wikilinkContents = choice( [
623 function wikilink() {
624 var result, parsedResult, parsedLinkContents;
627 parsedResult = sequence( [
632 if ( parsedResult !== null ) {
633 parsedLinkContents = parsedResult[ 1 ];
634 result = [ 'WIKILINK' ].concat( parsedLinkContents );
639 // TODO: Support data- if appropriate
640 function doubleQuotedHtmlAttributeValue() {
641 var parsedResult = sequence( [
643 htmlDoubleQuoteAttributeValue,
646 return parsedResult === null ? null : parsedResult[ 1 ];
649 function singleQuotedHtmlAttributeValue() {
650 var parsedResult = sequence( [
652 htmlSingleQuoteAttributeValue,
655 return parsedResult === null ? null : parsedResult[ 1 ];
658 function htmlAttribute() {
659 var parsedResult = sequence( [
661 asciiAlphabetLiteral,
664 doubleQuotedHtmlAttributeValue,
665 singleQuotedHtmlAttributeValue
668 return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
672 * Checks if HTML is allowed
674 * @param {string} startTagName HTML start tag name
675 * @param {string} endTagName HTML start tag name
676 * @param {Object} attributes array of consecutive key value pairs,
677 * with index 2 * n being a name and 2 * n + 1 the associated value
678 * @return {boolean} true if this is HTML is allowed, false otherwise
680 function isAllowedHtml( startTagName, endTagName, attributes ) {
681 var i, len, attributeName;
683 startTagName = startTagName.toLowerCase();
684 endTagName = endTagName.toLowerCase();
685 if ( startTagName !== endTagName || $.inArray( startTagName, settings.allowedHtmlElements ) === -1 ) {
689 for ( i = 0, len = attributes.length; i < len; i += 2 ) {
690 attributeName = attributes[ i ];
691 if ( $.inArray( attributeName, settings.allowedHtmlCommonAttributes ) === -1 &&
692 $.inArray( attributeName, settings.allowedHtmlAttributesByElement[ startTagName ] || [] ) === -1 ) {
700 function htmlAttributes() {
701 var parsedResult = nOrMore( 0, htmlAttribute )();
702 // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
703 return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
706 // Subset of allowed HTML markup.
707 // Most elements and many attributes allowed on the server are not supported yet.
709 var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
710 wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
711 startCloseTagPos, endOpenTagPos, endCloseTagPos,
714 // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
715 // 1. open through closeHtmlTag
717 // 3. openHtmlEnd through close
718 // This will allow recording the positions to reconstruct if HTML is to be treated as text.
720 startOpenTagPos = pos;
721 parsedOpenTagResult = sequence( [
723 asciiAlphabetLiteral,
725 optionalForwardSlash,
729 if ( parsedOpenTagResult === null ) {
734 startTagName = parsedOpenTagResult[ 1 ];
736 parsedHtmlContents = nOrMore( 0, expression )();
738 startCloseTagPos = pos;
739 parsedCloseTagResult = sequence( [
741 asciiAlphabetLiteral,
745 if ( parsedCloseTagResult === null ) {
746 // Closing tag failed. Return the start tag and contents.
747 return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
748 .concat( parsedHtmlContents );
751 endCloseTagPos = pos;
752 endTagName = parsedCloseTagResult[ 1 ];
753 wrappedAttributes = parsedOpenTagResult[ 2 ];
754 attributes = wrappedAttributes.slice( 1 );
755 if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
756 result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
757 .concat( parsedHtmlContents );
759 // HTML is not allowed, so contents will remain how
760 // it was, while HTML markup at this level will be
762 // E.g. assuming script tags are not allowed:
764 // <script>[[Foo|bar]]</script>
766 // results in '<script>' and '</script>'
767 // (not treated as an HTML tag), surrounding a fully
770 // Concatenate everything from the tag, flattening the contents.
771 result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
772 .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
778 templateName = transform(
779 // see $wgLegalTitleChars
780 // not allowing : due to the need to catch "PLURAL:$1"
781 makeRegexParser( /^[ !"$&'()*,.\/0-9;=?@A-Z\^_`a-z~\x80-\xFF+\-]+/ ),
782 function ( result ) { return result.toString(); }
784 function templateParam() {
788 nOrMore( 0, paramExpression )
790 if ( result === null ) {
794 // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
795 return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
798 function templateWithReplacement() {
799 var result = sequence( [
804 return result === null ? null : [ result[ 0 ], result[ 2 ] ];
806 function templateWithOutReplacement() {
807 var result = sequence( [
812 return result === null ? null : [ result[ 0 ], result[ 2 ] ];
814 function templateWithOutFirstParameter() {
815 var result = sequence( [
819 return result === null ? null : [ result[ 0 ], '' ];
821 colon = makeStringParser( ':' );
822 templateContents = choice( [
824 var res = sequence( [
825 // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
826 // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
827 choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
828 nOrMore( 0, templateParam )
830 return res === null ? null : res[ 0 ].concat( res[ 1 ] );
833 var res = sequence( [
835 nOrMore( 0, templateParam )
837 if ( res === null ) {
840 return [ res[ 0 ] ].concat( res[ 1 ] );
843 openTemplate = makeStringParser( '{{' );
844 closeTemplate = makeStringParser( '}}' );
845 nonWhitespaceExpression = choice( [
852 paramExpression = choice( [
860 expression = choice( [
869 // Used when only {{-transformation is wanted, for 'text'
870 // or 'escaped' formats
871 curlyBraceTransformExpression = choice( [
874 curlyBraceTransformExpressionLiteral
880 * @param {Function} rootExpression root parse function
882 function start( rootExpression ) {
883 var result = nOrMore( 0, rootExpression )();
884 if ( result === null ) {
887 return [ 'CONCAT' ].concat( result );
889 // everything above this point is supposed to be stateless/static, but
890 // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
891 // finally let's do some actual work...
893 result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
896 * For success, the p must have gotten to the end of the input
897 * and returned a non-null.
898 * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
900 if ( result === null || pos !== input.length ) {
901 throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
909 * htmlEmitter - object which primarily exists to emit HTML from parser ASTs
911 mw.jqueryMsg.htmlEmitter = function ( language, magic ) {
912 this.language = language;
914 $.each( magic, function ( key, val ) {
915 jmsg[ key.toLowerCase() ] = function () {
921 * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
922 * Walk entire node structure, applying replacements and template functions when appropriate
924 * @param {Mixed} node Abstract syntax tree (top node or subnode)
925 * @param {Array} replacements for $1, $2, ... $n
926 * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
928 this.emit = function ( node, replacements ) {
929 var ret, subnodes, operation,
931 switch ( typeof node ) {
936 // typeof returns object for arrays
938 // node is an array of nodes
939 subnodes = $.map( node.slice( 1 ), function ( n ) {
940 return jmsg.emit( n, replacements );
942 operation = node[ 0 ].toLowerCase();
943 if ( typeof jmsg[ operation ] === 'function' ) {
944 ret = jmsg[ operation ]( subnodes, replacements );
946 throw new Error( 'Unknown operation "' + operation + '"' );
950 // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
951 // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
952 // The logical thing is probably to return the empty string here when we encounter undefined.
956 throw new Error( 'Unexpected type in AST: ' + typeof node );
962 // For everything in input that follows double-open-curly braces, there should be an equivalent parser
963 // function. For instance {{PLURAL ... }} will be processed by 'plural'.
964 // If you have 'magic words' then configure the parser to have them upon creation.
966 // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
967 // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
968 mw.jqueryMsg.htmlEmitter.prototype = {
970 * Parsing has been applied depth-first we can assume that all nodes here are single nodes
971 * Must return a single node to parents -- a jQuery with synthetic span
972 * However, unwrap any other synthetic spans in our children and pass them upwards
974 * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
977 concat: function ( nodes ) {
978 var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
979 $.each( nodes, function ( i, node ) {
980 // Let jQuery append nodes, arrays of nodes and jQuery objects
981 // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
982 appendWithoutParsing( $span, node );
988 * Return escaped replacement of correct index, or string if unavailable.
989 * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
990 * if the specified parameter is not found return the same string
991 * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
993 * TODO: Throw error if nodes.length > 1 ?
995 * @param {Array} nodes List of one element, integer, n >= 0
996 * @param {Array} replacements List of at least n strings
997 * @return {string} replacement
999 replace: function ( nodes, replacements ) {
1000 var index = parseInt( nodes[ 0 ], 10 );
1002 if ( index < replacements.length ) {
1003 return replacements[ index ];
1005 // index not found, fallback to displaying variable
1006 return '$' + ( index + 1 );
1011 * Transform wiki-link
1014 * It only handles basic cases, either no pipe, or a pipe with an explicit
1017 * It does not attempt to handle features like the pipe trick.
1018 * However, the pipe trick should usually not be present in wikitext retrieved
1019 * from the server, since the replacement is done at save time.
1020 * It may, though, if the wikitext appears in extension-controlled content.
1022 * @param {string[]} nodes
1024 wikilink: function ( nodes ) {
1025 var page, anchor, url, $el;
1027 page = textify( nodes[ 0 ] );
1028 // Strip leading ':', which is used to suppress special behavior in wikitext links,
1029 // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
1030 if ( page.charAt( 0 ) === ':' ) {
1031 page = page.slice( 1 );
1033 url = mw.util.getUrl( page );
1035 if ( nodes.length === 1 ) {
1036 // [[Some Page]] or [[Namespace:Some Page]]
1039 // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
1040 anchor = nodes[ 1 ];
1043 $el = $( '<a>' ).attr( {
1047 return appendWithoutParsing( $el, anchor );
1051 * Converts array of HTML element key value pairs to object
1053 * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
1054 * name and 2 * n + 1 the associated value
1055 * @return {Object} Object mapping attribute name to attribute value
1057 htmlattributes: function ( nodes ) {
1058 var i, len, mapping = {};
1059 for ( i = 0, len = nodes.length; i < len; i += 2 ) {
1060 mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
1066 * Handles an (already-validated) HTML element.
1068 * @param {Array} nodes Nodes to process when creating element
1069 * @return {jQuery|Array} jQuery node for valid HTML or array for disallowed element
1071 htmlelement: function ( nodes ) {
1072 var tagName, attributes, contents, $element;
1074 tagName = nodes.shift();
1075 attributes = nodes.shift();
1077 $element = $( document.createElement( tagName ) ).attr( attributes );
1078 return appendWithoutParsing( $element, contents );
1082 * Transform parsed structure into external link.
1084 * The "href" can be:
1085 * - a jQuery object, treat it as "enclosing" the link text.
1086 * - a function, treat it as the click handler.
1087 * - a string, or our htmlEmitter jQuery object, treat it as a URI after stringifying.
1089 * TODO: throw an error if nodes.length > 2 ?
1091 * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
1094 extlink: function ( nodes ) {
1097 contents = nodes[ 1 ];
1098 if ( arg instanceof jQuery && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
1102 if ( typeof arg === 'function' ) {
1103 $el.attr( 'href', '#' )
1104 .click( function ( e ) {
1109 $el.attr( 'href', textify( arg ) );
1112 return appendWithoutParsing( $el.empty(), contents );
1116 * Transform parsed structure into pluralization
1117 * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
1118 * So convert it back with the current language's convertNumber.
1120 * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
1121 * @return {string} selected pluralized form according to current language
1123 plural: function ( nodes ) {
1124 var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
1125 explicitPluralForms = {};
1127 count = parseFloat( this.language.convertNumber( nodes[ 0 ], true ) );
1128 forms = nodes.slice( 1 );
1129 for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
1130 form = forms[ formIndex ];
1132 if ( form instanceof jQuery && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
1133 // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
1134 firstChild = form.contents().get( 0 );
1135 if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
1136 firstChildText = firstChild.textContent;
1137 if ( /^\d+=/.test( firstChildText ) ) {
1138 explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
1139 // Use the digit part as key and rest of first text node and
1140 // rest of child nodes as value.
1141 firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
1142 explicitPluralForms[ explicitPluralFormNumber ] = form;
1143 forms[ formIndex ] = undefined;
1146 } else if ( /^\d+=/.test( form ) ) {
1147 // Simple explicit plural forms like 12=a dozen
1148 explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
1149 explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
1150 forms[ formIndex ] = undefined;
1154 // Remove explicit plural forms from the forms. They were set undefined in the above loop.
1155 forms = $.map( forms, function ( form ) {
1159 return this.language.convertPlural( count, forms, explicitPluralForms );
1163 * Transform parsed structure according to gender.
1165 * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
1167 * The first node must be one of:
1168 * - the mw.user object (or a compatible one)
1169 * - an empty string - indicating the current user, same effect as passing the mw.user object
1170 * - a gender string ('male', 'female' or 'unknown')
1172 * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
1173 * @return {string} Selected gender form according to current language
1175 gender: function ( nodes ) {
1177 maybeUser = nodes[ 0 ],
1178 forms = nodes.slice( 1 );
1180 if ( maybeUser === '' ) {
1181 maybeUser = mw.user;
1184 // If we are passed a mw.user-like object, check their gender.
1185 // Otherwise, assume the gender string itself was passed .
1186 if ( maybeUser && maybeUser.options instanceof mw.Map ) {
1187 gender = maybeUser.options.get( 'gender' );
1192 return this.language.gender( gender, forms );
1196 * Transform parsed structure into grammar conversion.
1197 * Invoked by putting `{{grammar:form|word}}` in a message
1199 * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
1200 * @return {string} selected grammatical form according to current language
1202 grammar: function ( nodes ) {
1203 var form = nodes[ 0 ],
1205 return word && form && this.language.convertGrammar( word, form );
1209 * Tranform parsed structure into a int: (interface language) message include
1210 * Invoked by putting `{{int:othermessage}}` into a message
1212 * @param {Array} nodes List of nodes
1213 * @return {string} Other message
1215 'int': function ( nodes ) {
1216 var msg = nodes[ 0 ];
1217 return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
1221 * Get localized namespace name from canonical name or namespace number.
1222 * Invoked by putting `{{ns:foo}}` into a message
1224 * @param {Array} nodes List of nodes
1225 * @return {string} Localized namespace name
1227 ns: function ( nodes ) {
1228 var ns = $.trim( textify( nodes[ 0 ] ) );
1229 if ( !/^\d+$/.test( ns ) ) {
1230 ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
1232 ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
1237 * Takes an unformatted number (arab, no group separators and . as decimal separator)
1238 * and outputs it in the localized digit script and formatted with decimal
1239 * separator, according to the current language.
1241 * @param {Array} nodes List of nodes
1242 * @return {number|string} Formatted number
1244 formatnum: function ( nodes ) {
1245 var isInteger = ( nodes[ 1 ] && nodes[ 1 ] === 'R' ) ? true : false,
1246 number = nodes[ 0 ];
1248 return this.language.convertNumber( number, isInteger );
1254 * @param {Array} nodes List of nodes
1255 * @return {string} The given text, all in lowercase
1257 lc: function ( nodes ) {
1258 return textify( nodes[ 0 ] ).toLowerCase();
1264 * @param {Array} nodes List of nodes
1265 * @return {string} The given text, all in uppercase
1267 uc: function ( nodes ) {
1268 return textify( nodes[ 0 ] ).toUpperCase();
1272 * Lowercase first letter of input, leaving the rest unchanged
1274 * @param {Array} nodes List of nodes
1275 * @return {string} The given text, with the first character in lowercase
1277 lcfirst: function ( nodes ) {
1278 var text = textify( nodes[ 0 ] );
1279 return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
1283 * Uppercase first letter of input, leaving the rest unchanged
1285 * @param {Array} nodes List of nodes
1286 * @return {string} The given text, with the first character in uppercase
1288 ucfirst: function ( nodes ) {
1289 var text = textify( nodes[ 0 ] );
1290 return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
1294 // Deprecated! don't rely on gM existing.
1295 // The window.gM ought not to be required - or if required, not required here.
1296 // But moving it to extensions breaks it (?!)
1297 // Need to fix plugin so it could do attributes as well, then will be okay to remove this.
1298 // @deprecated since 1.23
1299 mw.log.deprecate( window, 'gM', mw.jqueryMsg.getMessageFunction(), 'Use mw.message( ... ).parse() instead.' );
1304 * @see mw.jqueryMsg#getPlugin
1306 $.fn.msg = mw.jqueryMsg.getPlugin();
1308 // Replace the default message parser with jqueryMsg
1309 oldParser = mw.Message.prototype.parser;
1310 mw.Message.prototype.parser = function () {
1311 if ( this.format === 'plain' || !/\{\{|[\[<>&]/.test( this.map.get( this.key ) ) ) {
1312 // Fall back to mw.msg's simple parser
1313 return oldParser.apply( this );
1316 if ( !this.map.hasOwnProperty( this.format ) ) {
1317 this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
1319 // For format 'escaped', escaping part is handled by mediawiki.js
1323 return this.map[ this.format ]( this.key, this.parameters );
1327 * Parse the message to DOM nodes, rather than HTML string like #parse.
1329 * This method is only available when jqueryMsg is loaded.
1332 * @member mw.Message
1335 mw.Message.prototype.parseDom = ( function () {
1336 var reusableParent = $( '<div>' );
1337 return function () {
1338 return reusableParent.msg( this.key, this.parameters ).contents().detach();
1342 }( mediaWiki, jQuery ) );