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