/*!
* Experimental advanced wikitext parser-emitter.
* See: https://www.mediawiki.org/wiki/Extension:UploadWizard/MessageParser for docs
*
* @author neilk@wikimedia.org
* @author mflaschen@wikimedia.org
*/
/**
* @typedef {string|string[]} module:mediawiki.jqueryMsg~Replacements
* @ignore
*/
/**
* @callback {Function} module:mediawiki.jqueryMsg~MessageFormatterFunction
* @param {Array<module:mediawiki.jqueryMsg~Replacements>} replacements Optional variable replacements (variadically or an array).
* This is a mixed array of strings or arrays of string. This is equivalent to Array<string|string[]> but cannot be documented until the
* jsdoc theme has been patched (T354716).
* @return {jQuery} Rendered HTML.
* @ignore
*/
/**
* @callback {Function} MessageFormatterFunctionGenerator
* @return {module:mediawiki.jqueryMsg~MessageFormatterFunction}
* @ignore
*/
const slice = Array.prototype.slice,
util = require( 'mediawiki.util' ),
mwString = require( 'mediawiki.String' ),
parserDefaults = {
// Magic words and their expansions. Server-side data is added to this below.
magic: {
PAGENAME: mw.config.get( 'wgPageName' ),
PAGENAMEE: util.wikiUrlencode( mw.config.get( 'wgPageName' ) ),
SERVERNAME: mw.config.get( 'wgServerName' ),
CONTENTLANGUAGE: mw.config.get( 'wgContentLanguage' )
},
// Whitelist for allowed HTML elements in wikitext.
// Self-closing tags are not currently supported.
// Filled in with server-side data below
allowedHtmlElements: [],
// Key tag name, value allowed attributes for that tag.
// See Sanitizer::setupAttributeWhitelist
allowedHtmlCommonAttributes: [
// HTML
'id',
'class',
'style',
'lang',
'dir',
'title',
// WAI-ARIA
'role'
],
// Attributes allowed for specific elements.
// Key is element name in lower case
// Value is array of allowed attributes for that element
allowedHtmlAttributesByElement: {},
messages: mw.messages,
language: mw.language,
// Same meaning as in mediawiki.js.
//
// Only 'text', 'parse', and 'escaped' are supported, and the
// actual escaping for 'escaped' is done by other code (generally
// through mediawiki.js).
//
// However, note that this default only
// applies to direct calls to jqueryMsg. The default for mediawiki.js itself
// is 'text', including when it uses jqueryMsg.
format: 'parse'
};
// Add in server-side data (allowedHtmlElements and magic words)
$.extend( true, parserDefaults, require( './parserDefaults.json' ) );
/**
* Wrapper around jQuery append that converts all non-objects to TextNode so append will not
* convert what it detects as an htmlString to an element.
*
* If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to
* new parent.
*
* Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
*
* @private
* @param {jQuery} $parent Parent node wrapped by jQuery
* @param {Object|string|Array} children What to append, with the same possible types as jQuery
* @return {jQuery} $parent
*/
function appendWithoutParsing( $parent, children ) {
let i, len;
if ( !Array.isArray( children ) ) {
children = [ children ];
}
for ( i = 0, len = children.length; i < len; i++ ) {
if ( typeof children[ i ] !== 'object' ) {
children[ i ] = document.createTextNode( children[ i ] );
}
if ( children[ i ] instanceof $ && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
children[ i ] = children[ i ].contents();
}
}
return $parent.append( children );
}
/**
* Decodes the main HTML entities, those encoded by mw.html.escape.
*
* @private
* @param {string} encoded Encoded string
* @return {string} String with those entities decoded
*/
function decodePrimaryHtmlEntities( encoded ) {
return encoded
.replace( /'/g, '\'' )
.replace( /"/g, '"' )
.replace( /</g, '<' )
.replace( />/g, '>' )
.replace( /&/g, '&' );
}
/**
* Turn input into a string.
*
* @private
* @param {string|jQuery} input
* @return {string} Textual value of input
*/
function textify( input ) {
if ( input instanceof $ ) {
input = input.text();
}
return String( input );
}
/**
* Given parser options, return a function that parses a key and replacements, returning jQuery object
*
* Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
* If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
* the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
*
* @private
* @param {Object} options Parser options
* @return {Function}
* @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
* @return {jQuery} return.return
*/
function getFailableParserFn( options ) {
return function ( args ) {
const parser = new Parser( options ),
key = args[ 0 ],
argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
try {
return parser.parse( key, argsArray );
} catch ( e ) {
const fallback = parser.settings.messages.get( key );
mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
mw.track( 'mediawiki.jqueryMsg.error', {
messageKey: key,
errorMessage: e.message
} );
return $( '<span>' ).text( fallback );
}
};
}
/**
* Initialize parser defaults.
*
* This is currently used by the QUnit testrunner to change the reference in
* parserDefaults.messages to the test messages and back.
*
* @private
* @param {Object} data New data to extend parser defaults with
*/
const setParserDefaults = function ( data ) {
Object.assign( parserDefaults, data );
};
/**
* Get current parser defaults.
*
* Primarily used for the unit test. Returns a copy.
*
* @private
* @return {Object}
*/
const getParserDefaults = function () {
return Object.assign( {}, parserDefaults );
};
/**
* Returns a function suitable for static use, to construct strings from a message key (and optional replacements).
*
* @ignore
* @param {Object} options parser options
* @return {module:mediawiki.jqueryMsg~MessageFormatterFunction}
*/
const defaultMessageFunction = function ( options ) {
let failableParserFn, format;
if ( options && options.format !== undefined ) {
format = options.format;
} else {
format = parserDefaults.format;
}
return function () {
if ( !failableParserFn ) {
failableParserFn = getFailableParserFn( options );
}
const $result = failableParserFn( arguments );
if ( format === 'text' || format === 'escaped' ) {
return $result.text();
} else {
return $result.html();
}
};
};
/**
* @type {MessageFormatterFunctionGenerator}
* @ignore
*/
let messageFunction = defaultMessageFunction;
/**
* @ignore
* @param {Object} options parser options
* @return {module:mediawiki.jqueryMsg~MessageFormatterFunction} options
*/
const getMessageFunction = function ( options ) {
return messageFunction( options );
};
/**
* Allows tests to override the message function.
*
* @ignore
* @param {MessageFormatterFunctionGenerator} msgFunction
* @return {Function} that allows you to restore the original message function.
*/
const setMessageFunction = function ( msgFunction ) {
messageFunction = msgFunction;
return function () {
messageFunction = defaultMessageFunction;
};
};
/**
* Returns a jQuery plugin.
*
* @ignore
* @param {Object} [options] Parser options
* @return {module:mediawiki.jqueryMsg~MessageFormatterFunction}
*/
const getPlugin = function ( options ) {
let failableParserFn;
return function () {
if ( !failableParserFn ) {
failableParserFn = getFailableParserFn( options );
}
const $result = failableParserFn( arguments );
return this.empty().append( $result.contents() );
};
};
/**
* The parser itself.
* Describes an object, whose primary duty is to .parse() message keys.
*
* @class
* @private
* @param {Object} options
*/
function Parser( options ) {
this.settings = Object.assign( {}, parserDefaults, options );
this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
this.astCache = {};
this.emitter = new HtmlEmitter( this.settings.language, this.settings.magic );
}
Parser.prototype = {
/**
* Where the magic happens.
* Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
* If an error is thrown, returns original key, and logs the error
*
* @param {string} key Message key.
* @param {Array} replacements Variable replacements for $1, $2... $n
* @return {jQuery}
*/
parse: function ( key, replacements ) {
const ast = this.getAst( key, replacements );
return this.emitter.emit( ast, replacements );
},
/**
* Fetch the message string associated with a key, return parsed structure. Memoized.
* Note that we pass '⧼' + key + '⧽' back for a missing message here.
*
* @param {string} key
* @param {Array} replacements Variable replacements for $1, $2... $n
* @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing
*/
getAst: function ( key, replacements ) {
if ( !Object.prototype.hasOwnProperty.call( this.astCache, key ) ) {
let wikiText = this.settings.messages.get( key );
// Keep this synchronised with Message#parser in mediawiki.base.js
if (
mw.config.get( 'wgUserLanguage' ) === 'qqx' &&
( !wikiText || wikiText === '(' + key + ')' )
) {
wikiText = '(' + key + '$*)';
} else if ( typeof wikiText !== 'string' ) {
wikiText = '⧼' + key + '⧽';
}
wikiText = mw.internalDoTransformFormatForQqx( wikiText, replacements );
this.astCache[ key ] = this.wikiTextToAst( wikiText );
}
return this.astCache[ key ];
},
/**
* Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
*
* CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
* n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
*
* @param {string} input Message string wikitext
* @throws Error
* @return {any} abstract syntax tree
*/
wikiTextToAst: function ( input ) {
let nonWhitespaceExpression = null, expression = null, templateContents = null, paramExpression = null, colon = null;
const settings = this.settings;
// Indicates current position in input as we parse through it.
// Shared among all parsing functions below.
let pos = 0;
// =========================================================
// parsing combinators - could be a library on its own
// =========================================================
/**
* Try parsers until one works, if none work return null
*
* @private
* @param {Function[]} ps
* @return {Function} that will return {string|null}
*/
function choice( ps ) {
return function () {
let i, result;
for ( i = 0; i < ps.length; i++ ) {
result = ps[ i ]();
if ( result !== null ) {
return result;
}
}
return null;
};
}
/**
* Try several ps in a row, all must succeed or return null.
* This is the only eager one.
*
* @private
* @param {Function[]} ps Each function should return a string or null
* @return {string[]|null}
*/
function sequence( ps ) {
const originalPos = pos,
result = [];
for ( let i = 0; i < ps.length; i++ ) {
const r = ps[ i ]();
if ( r === null ) {
pos = originalPos;
return null;
}
result.push( r );
}
return result;
}
/**
* Run the same parser over and over until it fails.
* Must succeed a minimum of n times or return null.
*
* @private
* @param {number} n
* @param {Function} p Should return a string or null
* @return {Function} that will return {string[]|null}
*/
function nOrMore( n, p ) {
return function () {
const originalPos = pos,
result = [];
let parsed = p();
while ( parsed !== null ) {
result.push( parsed );
parsed = p();
}
if ( result.length < n ) {
pos = originalPos;
return null;
}
return result;
};
}
/**
* Just make parsers out of simpler JS builtin types
*
* @private
* @param {string} s
* @return {Function} that will return {string|null}
*/
function makeStringParser( s ) {
const len = s.length;
return function () {
let result = null;
if ( input.slice( pos, pos + len ) === s ) {
result = s;
pos += len;
}
return result;
};
}
/**
* Makes a regex parser, given a RegExp object.
* The regex being passed in should start with a ^ to anchor it to the start
* of the string.
*
* @private
* @param {RegExp} regex anchored regex
* @return {Function} function to parse input based on the regex
*/
function makeRegexParser( regex ) {
return function () {
const matches = input.slice( pos ).match( regex );
if ( matches === null ) {
return null;
}
pos += matches[ 0 ].length;
return matches[ 0 ];
};
}
// ===================================================================
// General patterns above this line -- wikitext specific parsers below
// ===================================================================
// Parsing functions follow. All parsing functions work like this:
// They don't accept any arguments.
// Instead, they just operate non destructively on the string 'input'
// As they can consume parts of the string, they advance the shared variable pos,
// and return tokens (or whatever else they want to return).
// some things are defined as closures and other things as ordinary functions
// converting everything to a closure makes it a lot harder to debug... errors pop up
// but some debuggers can't tell you exactly where they come from. Also the mutually
// recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
// This may be because, to save code, memoization was removed
/* eslint-disable no-useless-escape */
const regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
const regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
const regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
/* eslint-enable no-useless-escape */
const backslash = makeStringParser( '\\' );
const anyCharacter = makeRegexParser( /^./ );
function escapedLiteral() {
const result = sequence( [
backslash,
anyCharacter
] );
return result === null ? null : result[ 1 ];
}
const escapedOrLiteralWithoutSpace = choice( [
escapedLiteral,
regularLiteralWithoutSpace
] );
const escapedOrLiteralWithoutBar = choice( [
escapedLiteral,
regularLiteralWithoutBar
] );
const escapedOrRegularLiteral = choice( [
escapedLiteral,
regularLiteral
] );
// Used to define "literals" without spaces, in space-delimited situations
function literalWithoutSpace() {
const result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
return result === null ? null : result.join( '' );
}
// Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
// it is not a literal in the parameter
function literalWithoutBar() {
const result = nOrMore( 1, escapedOrLiteralWithoutBar )();
return result === null ? null : result.join( '' );
}
function literal() {
const result = nOrMore( 1, escapedOrRegularLiteral )();
return result === null ? null : result.join( '' );
}
const asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
const whitespace = makeRegexParser( /^\s+/ );
const dollar = makeStringParser( '$' );
const digits = makeRegexParser( /^\d+/ );
function replacement() {
const result = sequence( [
dollar,
digits
] );
if ( result === null ) {
return null;
}
return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
}
const openExtlink = makeStringParser( '[' );
const closeExtlink = makeStringParser( ']' );
// this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
function extlink() {
const parsedResult = sequence( [
openExtlink,
nOrMore( 1, nonWhitespaceExpression ),
whitespace,
nOrMore( 1, expression ),
closeExtlink
] );
if ( parsedResult === null ) {
return null;
}
// When the entire link target is a single parameter, we can't use CONCAT, as we allow
// passing fancy parameters (like a whole jQuery object or a function) to use for the
// link. Check only if it's a single match, since we can either do CONCAT or not for
// singles with the same effect.
const target = parsedResult[ 1 ].length === 1 ?
parsedResult[ 1 ][ 0 ] :
[ 'CONCAT', ...parsedResult[ 1 ] ];
return [
'EXTLINK',
target,
[ 'CONCAT', ...parsedResult[ 3 ] ]
];
}
const pipe = makeStringParser( '|' );
const openTemplate = makeStringParser( '{{' );
const closeTemplate = makeStringParser( '}}' );
function template() {
const result = sequence( [
openTemplate,
templateContents,
closeTemplate
] );
return result === null ? null : result[ 1 ];
}
function templateName() {
// see $wgLegalTitleChars
// not allowing : due to the need to catch "PLURAL:$1"
const templateNameRegex = makeRegexParser( /^#?[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ );
const result = templateNameRegex();
return result === null ? null : result.toString();
}
function templateParam() {
const result = sequence( [
pipe,
nOrMore( 0, paramExpression )
] );
if ( result === null ) {
return null;
}
const expr = result[ 1 ];
// use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
return expr.length > 1 ? [ 'CONCAT', ...expr ] : expr[ 0 ];
}
function templateNameWithParam() {
const result = sequence( [
templateName,
colon,
nOrMore( 0, paramExpression )
] );
if ( result === null ) {
return null;
}
const expr = result[ 2 ];
// use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
return [ result[ 0 ], expr.length > 1 ? [ 'CONCAT', ...expr ] : expr[ 0 ] ];
}
colon = makeStringParser( ':' );
templateContents = choice( [
function () {
const result = sequence( [
templateNameWithParam,
nOrMore( 0, templateParam )
] );
return result === null ? null : [ ...result[ 0 ], ...result[ 1 ] ];
},
function () {
const result = sequence( [
templateName,
nOrMore( 0, templateParam )
] );
if ( result === null ) {
return null;
}
return [ result[ 0 ], ...result[ 1 ] ];
}
] );
function pipedWikilink() {
const result = sequence( [
nOrMore( 1, paramExpression ),
pipe,
nOrMore( 1, expression )
] );
return result === null ? null : [
[ 'CONCAT', ...result[ 0 ] ],
[ 'CONCAT', ...result[ 2 ] ]
];
}
function unpipedWikilink() {
const result = sequence( [
nOrMore( 1, paramExpression )
] );
return result === null ? null : [
[ 'CONCAT', ...result[ 0 ] ]
];
}
const wikilinkContents = choice( [
pipedWikilink,
unpipedWikilink
] );
const openWikilink = makeStringParser( '[[' );
const closeWikilink = makeStringParser( ']]' );
function wikilink() {
const parsedResult = sequence( [
openWikilink,
wikilinkContents,
closeWikilink
] );
return parsedResult === null ? null : [ 'WIKILINK', ...parsedResult[ 1 ] ];
}
// TODO: Support data- if appropriate
const doubleQuote = makeStringParser( '"' );
const htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
function doubleQuotedHtmlAttributeValue() {
const parsedResult = sequence( [
doubleQuote,
htmlDoubleQuoteAttributeValue,
doubleQuote
] );
return parsedResult === null ? null : parsedResult[ 1 ];
}
const singleQuote = makeStringParser( '\'' );
const htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
function singleQuotedHtmlAttributeValue() {
const parsedResult = sequence( [
singleQuote,
htmlSingleQuoteAttributeValue,
singleQuote
] );
return parsedResult === null ? null : parsedResult[ 1 ];
}
const htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
function htmlAttribute() {
const parsedResult = sequence( [
whitespace,
asciiAlphabetLiteral,
htmlAttributeEquals,
choice( [
doubleQuotedHtmlAttributeValue,
singleQuotedHtmlAttributeValue
] )
] );
return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
}
/**
* Checks if HTML is allowed
*
* @param {string} startTagName HTML start tag name
* @param {string} endTagName HTML start tag name
* @param {Object} attributes array of consecutive key value pairs,
* with index 2 * n being a name and 2 * n + 1 the associated value
* @return {boolean} true if this is HTML is allowed, false otherwise
* @ignore
*/
function isAllowedHtml( startTagName, endTagName, attributes ) {
startTagName = startTagName.toLowerCase();
endTagName = endTagName.toLowerCase();
if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) {
return false;
}
const badStyle = /[\000-\010\013\016-\037\177]|expression|filter\s*:|accelerator\s*:|-o-link\s*:|-o-link-source\s*:|-o-replace\s*:|url\s*\(|image\s*\(|image-set\s*\(/i;
let attributeName;
for ( let i = 0, len = attributes.length; i < len; i += 2 ) {
attributeName = attributes[ i ];
if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 &&
( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) {
return false;
}
if ( attributeName === 'style' && attributes[ i + 1 ].search( badStyle ) !== -1 ) {
mw.log( 'HTML tag not parsed due to dangerous style attribute' );
return false;
}
}
return true;
}
function htmlAttributes() {
const parsedResult = nOrMore( 0, htmlAttribute )();
// Un-nest attributes array due to structure of jQueryMsg operations (see emit).
return [ 'HTMLATTRIBUTES' ].concat( ...parsedResult );
}
const openHtmlStartTag = makeStringParser( '<' );
const optionalForwardSlash = makeRegexParser( /^\/?/ );
const openHtmlEndTag = makeStringParser( '</' );
const closeHtmlTag = makeRegexParser( /^\s*>/ );
// Subset of allowed HTML markup.
// Most elements and many attributes allowed on the server are not supported yet.
function html() {
// Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
// 1. open through closeHtmlTag
// 2. expression
// 3. openHtmlEnd through close
// This will allow recording the positions to reconstruct if HTML is to be treated as text.
const startOpenTagPos = pos;
const parsedOpenTagResult = sequence( [
openHtmlStartTag,
asciiAlphabetLiteral,
htmlAttributes,
optionalForwardSlash,
closeHtmlTag
] );
if ( parsedOpenTagResult === null ) {
return null;
}
const endOpenTagPos = pos;
const startTagName = parsedOpenTagResult[ 1 ];
const parsedHtmlContents = nOrMore( 0, expression )();
const startCloseTagPos = pos;
const parsedCloseTagResult = sequence( [
openHtmlEndTag,
asciiAlphabetLiteral,
closeHtmlTag
] );
if ( parsedCloseTagResult === null ) {
// Closing tag failed. Return the start tag and contents.
return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ),
...parsedHtmlContents ];
}
const endCloseTagPos = pos;
const endTagName = parsedCloseTagResult[ 1 ];
const wrappedAttributes = parsedOpenTagResult[ 2 ];
const attributes = wrappedAttributes.slice( 1 );
if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
return [ 'HTMLELEMENT', startTagName, wrappedAttributes,
...parsedHtmlContents ];
}
// HTML is not allowed, so contents will remain how
// it was, while HTML markup at this level will be
// treated as text
// E.g. assuming script tags are not allowed:
//
// <script>[[Foo|bar]]</script>
//
// results in '<script>' and '</script>'
// (not treated as an HTML tag), surrounding a fully
// parsed HTML link.
//
// Concatenate everything from the tag, flattening the contents.
return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ),
...parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) ];
}
// <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
function nowiki() {
const parsedResult = sequence( [
makeStringParser( '<nowiki>' ),
// We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
makeStringParser( '</nowiki>' )
] );
return parsedResult === null ? null : [ 'CONCAT', ...parsedResult[ 1 ] ];
}
nonWhitespaceExpression = choice( [
template,
wikilink,
extlink,
replacement,
literalWithoutSpace
] );
paramExpression = choice( [
template,
wikilink,
extlink,
replacement,
literalWithoutBar
] );
expression = choice( [
template,
wikilink,
extlink,
replacement,
nowiki,
html,
literal
] );
const regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
function curlyBraceTransformExpressionLiteral() {
const result = nOrMore( 1, regularLiteralWithSquareBrackets )();
return result === null ? null : result.join( '' );
}
// Used when only {{-transformation is wanted, for 'text'
// or 'escaped' formats
const curlyBraceTransformExpression = choice( [
template,
replacement,
curlyBraceTransformExpressionLiteral
] );
/**
* Starts the parse
*
* @param {Function} rootExpression Root parse function
* @return {Array|null}
* @ignore
*/
function start( rootExpression ) {
const result = nOrMore( 0, rootExpression )();
if ( result === null ) {
return null;
}
return [ 'CONCAT', ...result ];
}
// everything above this point is supposed to be stateless/static, but
// I am deferring the work of turning it into prototypes & objects. It's quite fast enough
// finally let's do some actual work...
const res = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
/*
* For success, the p must have gotten to the end of the input
* and returned a non-null.
* n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
*/
if ( res === null || pos !== input.length ) {
throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
}
return res;
}
};
/**
* Class that primarily exists to emit HTML from parser ASTs.
*
* @private
* @class
* @param {mw.language} language
* @param {Object.<string,string>} [magic]
*/
function HtmlEmitter( language, magic ) {
this.language = language;
Object.keys( magic || {} ).forEach( ( key ) => {
const val = magic[ key ];
this[ key.toLowerCase() ] = function () {
return val;
};
} );
/**
* (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
* Walk entire node structure, applying replacements and template functions when appropriate
*
* @param {any} node Abstract syntax tree (top node or subnode)
* @param {Array} replacements for $1, $2, ... $n
* @return {any} single-string node or array of nodes suitable for jQuery appending
*/
this.emit = ( node, replacements ) => {
switch ( typeof node ) {
case 'string':
case 'number':
return node;
// typeof returns object for arrays
case 'object': {
// node is an array of nodes
// eslint-disable-next-line no-jquery/no-map-util
const subnodes = $.map( node.slice( 1 ), ( n ) => this.emit( n, replacements ) );
const operation = node[ 0 ].toLowerCase();
if ( typeof this[ operation ] === 'function' ) {
return this[ operation ]( subnodes, replacements );
} else {
throw new Error( 'Unknown operation "' + operation + '"' );
}
}
case 'undefined':
// Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
// Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
// The logical thing is probably to return the empty string here when we encounter undefined.
return '';
default:
throw new Error( 'Unexpected type in AST: ' + typeof node );
}
};
}
// BIDI utility function, copied from jquery.i18n.emitter.bidi.js
//
// Matches the first strong directionality codepoint:
// - in group 1 if it is LTR
// - in group 2 if it is RTL
// Does not match if there is no strong directionality codepoint.
//
// Generated by UnicodeJS (see tools/strongDir) from the UCD; see
// https://gerrit.wikimedia.org/g/unicodejs .
// eslint-disable-next-line no-misleading-character-class
const strongDirRegExp = new RegExp(
'(?:' +
'(' +
'[\u0041-\u005a\u0061-\u007a\u00aa\u00b5\u00ba\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02b8\u02bb-\u02c1\u02d0\u02d1\u02e0-\u02e4\u02ee\u0370-\u0373\u0376\u0377\u037a-\u037d\u037f\u0386\u0388-\u038a\u038c\u038e-\u03a1\u03a3-\u03f5\u03f7-\u0482\u048a-\u052f\u0531-\u0556\u0559-\u055f\u0561-\u0587\u0589\u0903-\u0939\u093b\u093d-\u0940\u0949-\u094c\u094e-\u0950\u0958-\u0961\u0964-\u0980\u0982\u0983\u0985-\u098c\u098f\u0990\u0993-\u09a8\u09aa-\u09b0\u09b2\u09b6-\u09b9\u09bd-\u09c0\u09c7\u09c8\u09cb\u09cc\u09ce\u09d7\u09dc\u09dd\u09df-\u09e1\u09e6-\u09f1\u09f4-\u09fa\u0a03\u0a05-\u0a0a\u0a0f\u0a10\u0a13-\u0a28\u0a2a-\u0a30\u0a32\u0a33\u0a35\u0a36\u0a38\u0a39\u0a3e-\u0a40\u0a59-\u0a5c\u0a5e\u0a66-\u0a6f\u0a72-\u0a74\u0a83\u0a85-\u0a8d\u0a8f-\u0a91\u0a93-\u0aa8\u0aaa-\u0ab0\u0ab2\u0ab3\u0ab5-\u0ab9\u0abd-\u0ac0\u0ac9\u0acb\u0acc\u0ad0\u0ae0\u0ae1\u0ae6-\u0af0\u0af9\u0b02\u0b03\u0b05-\u0b0c\u0b0f\u0b10\u0b13-\u0b28\u0b2a-\u0b30\u0b32\u0b33\u0b35-\u0b39\u0b3d\u0b3e\u0b40\u0b47\u0b48\u0b4b\u0b4c\u0b57\u0b5c\u0b5d\u0b5f-\u0b61\u0b66-\u0b77\u0b83\u0b85-\u0b8a\u0b8e-\u0b90\u0b92-\u0b95\u0b99\u0b9a\u0b9c\u0b9e\u0b9f\u0ba3\u0ba4\u0ba8-\u0baa\u0bae-\u0bb9\u0bbe\u0bbf\u0bc1\u0bc2\u0bc6-\u0bc8\u0bca-\u0bcc\u0bd0\u0bd7\u0be6-\u0bf2\u0c01-\u0c03\u0c05-\u0c0c\u0c0e-\u0c10\u0c12-\u0c28\u0c2a-\u0c39\u0c3d\u0c41-\u0c44\u0c58-\u0c5a\u0c60\u0c61\u0c66-\u0c6f\u0c7f\u0c82\u0c83\u0c85-\u0c8c\u0c8e-\u0c90\u0c92-\u0ca8\u0caa-\u0cb3\u0cb5-\u0cb9\u0cbd-\u0cc4\u0cc6-\u0cc8\u0cca\u0ccb\u0cd5\u0cd6\u0cde\u0ce0\u0ce1\u0ce6-\u0cef\u0cf1\u0cf2\u0d02\u0d03\u0d05-\u0d0c\u0d0e-\u0d10\u0d12-\u0d3a\u0d3d-\u0d40\u0d46-\u0d48\u0d4a-\u0d4c\u0d4e\u0d57\u0d5f-\u0d61\u0d66-\u0d75\u0d79-\u0d7f\u0d82\u0d83\u0d85-\u0d96\u0d9a-\u0db1\u0db3-\u0dbb\u0dbd\u0dc0-\u0dc6\u0dcf-\u0dd1\u0dd8-\u0ddf\u0de6-\u0def\u0df2-\u0df4\u0e01-\u0e30\u0e32\u0e33\u0e40-\u0e46\u0e4f-\u0e5b\u0e81\u0e82\u0e84\u0e87\u0e88\u0e8a\u0e8d\u0e94-\u0e97\u0e99-\u0e9f\u0ea1-\u0ea3\u0ea5\u0ea7\u0eaa\u0eab\u0ead-\u0eb0\u0eb2\u0eb3\u0ebd\u0ec0-\u0ec4\u0ec6\u0ed0-\u0ed9\u0edc-\u0edf\u0f00-\u0f17\u0f1a-\u0f34\u0f36\u0f38\u0f3e-\u0f47\u0f49-\u0f6c\u0f7f\u0f85\u0f88-\u0f8c\u0fbe-\u0fc5\u0fc7-\u0fcc\u0fce-\u0fda\u1000-\u102c\u1031\u1038\u103b\u103c\u103f-\u1057\u105a-\u105d\u1061-\u1070\u1075-\u1081\u1083\u1084\u1087-\u108c\u108e-\u109c\u109e-\u10c5\u10c7\u10cd\u10d0-\u1248\u124a-\u124d\u1250-\u1256\u1258\u125a-\u125d\u1260-\u1288\u128a-\u128d\u1290-\u12b0\u12b2-\u12b5\u12b8-\u12be\u12c0\u12c2-\u12c5\u12c8-\u12d6\u12d8-\u1310\u1312-\u1315\u1318-\u135a\u1360-\u137c\u1380-\u138f\u13a0-\u13f5\u13f8-\u13fd\u1401-\u167f\u1681-\u169a\u16a0-\u16f8\u1700-\u170c\u170e-\u1711\u1720-\u1731\u1735\u1736\u1740-\u1751\u1760-\u176c\u176e-\u1770\u1780-\u17b3\u17b6\u17be-\u17c5\u17c7\u17c8\u17d4-\u17da\u17dc\u17e0-\u17e9\u1810-\u1819\u1820-\u1877\u1880-\u18a8\u18aa\u18b0-\u18f5\u1900-\u191e\u1923-\u1926\u1929-\u192b\u1930\u1931\u1933-\u1938\u1946-\u196d\u1970-\u1974\u1980-\u19ab\u19b0-\u19c9\u19d0-\u19da\u1a00-\u1a16\u1a19\u1a1a\u1a1e-\u1a55\u1a57\u1a61\u1a63\u1a64\u1a6d-\u1a72\u1a80-\u1a89\u1a90-\u1a99\u1aa0-\u1aad\u1b04-\u1b33\u1b35\u1b3b\u1b3d-\u1b41\u1b43-\u1b4b\u1b50-\u1b6a\u1b74-\u1b7c\u1b82-\u1ba1\u1ba6\u1ba7\u1baa\u1bae-\u1be5\u1be7\u1bea-\u1bec\u1bee\u1bf2\u1bf3\u1bfc-\u1c2b\u1c34\u1c35\u1c3b-\u1c49\u1c4d-\u1c7f\u1cc0-\u1cc7\u1cd3\u1ce1\u1ce9-\u1cec\u1cee-\u1cf3\u1cf5\u1cf6\u1d00-\u1dbf\u1e00-\u1f15\u1f18-\u1f1d\u1f20-\u1f45\u1f48-\u1f4d\u1f50-\u1f57\u1f59\u1f5b\u1f5d\u1f5f-\u1f7d\u1f80-\u1fb4\u1fb6-\u1fbc\u1fbe\u1fc2-\u1fc4\u1fc6-\u1fcc\u1fd0-\u1fd3\u1fd6-\u1fdb\u1fe0-\u1fec\u1ff2-\u1ff4\u1ff6-\u1ffc\u200e\u2071\u207f\u2090-\u209c\u2102\u2107\u210a-\u2113\u2115\u2119-\u211d\u2124\u2126\u2128\u212a-\u212d\u212f-\u2139\u213c-\u213f\u2145-\u2149\u214e\u214f\u2160-\u2188\u2336-\u237a\u2395\u249c-\u24e9\u26ac\u2800-\u28ff\u2c00-\u2c2e\u2c30-\u2c5e\u2c60-\u2ce4\u2ceb-\u2cee\u2cf2\u2cf3\u2d00-\u2d25\u2d27\u2d2d\u2d30-\u2d67\u2d6f\u2d70\u2d80-\u2d96\u2da0-\u2da6\u2da8-\u2dae\u2db0-\u2db6\u2db8-\u2dbe\u2dc0-\u2dc6\u2dc8-\u2dce\u2dd0-\u2dd6\u2dd8-\u2dde\u3005-\u3007\u3021-\u3029\u302e\u302f\u3031-\u3035\u3038-\u303c\u3041-\u3096\u309d-\u309f\u30a1-\u30fa\u30fc-\u30ff\u3105-\u312d\u3131-\u318e\u3190-\u31ba\u31f0-\u321c\u3220-\u324f\u3260-\u327b\u327f-\u32b0\u32c0-\u32cb\u32d0-\u32fe\u3300-\u3376\u337b-\u33dd\u33e0-\u33fe\u3400-\u4db5\u4e00-\u9fd5\ua000-\ua48c\ua4d0-\ua60c\ua610-\ua62b\ua640-\ua66e\ua680-\ua69d\ua6a0-\ua6ef\ua6f2-\ua6f7\ua722-\ua787\ua789-\ua7ad\ua7b0-\ua7b7\ua7f7-\ua801\ua803-\ua805\ua807-\ua80a\ua80c-\ua824\ua827\ua830-\ua837\ua840-\ua873\ua880-\ua8c3\ua8ce-\ua8d9\ua8f2-\ua8fd\ua900-\ua925\ua92e-\ua946\ua952\ua953\ua95f-\ua97c\ua983-\ua9b2\ua9b4\ua9b5\ua9ba\ua9bb\ua9bd-\ua9cd\ua9cf-\ua9d9\ua9de-\ua9e4\ua9e6-\ua9fe\uaa00-\uaa28\uaa2f\uaa30\uaa33\uaa34\uaa40-\uaa42\uaa44-\uaa4b\uaa4d\uaa50-\uaa59\uaa5c-\uaa7b\uaa7d-\uaaaf\uaab1\uaab5\uaab6\uaab9-\uaabd\uaac0\uaac2\uaadb-\uaaeb\uaaee-\uaaf5\uab01-\uab06\uab09-\uab0e\uab11-\uab16\uab20-\uab26\uab28-\uab2e\uab30-\uab65\uab70-\uabe4\uabe6\uabe7\uabe9-\uabec\uabf0-\uabf9\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\ue000-\ufa6d\ufa70-\ufad9\ufb00-\ufb06\ufb13-\ufb17\uff21-\uff3a\uff41-\uff5a\uff66-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc]|\ud800[\udc00-\udc0b]|\ud800[\udc0d-\udc26]|\ud800[\udc28-\udc3a]|\ud800\udc3c|\ud800\udc3d|\ud800[\udc3f-\udc4d]|\ud800[\udc50-\udc5d]|\ud800[\udc80-\udcfa]|\ud800\udd00|\ud800\udd02|\ud800[\udd07-\udd33]|\ud800[\udd37-\udd3f]|\ud800[\uddd0-\uddfc]|\ud800[\ude80-\ude9c]|\ud800[\udea0-\uded0]|\ud800[\udf00-\udf23]|\ud800[\udf30-\udf4a]|\ud800[\udf50-\udf75]|\ud800[\udf80-\udf9d]|\ud800[\udf9f-\udfc3]|\ud800[\udfc8-\udfd5]|\ud801[\udc00-\udc9d]|\ud801[\udca0-\udca9]|\ud801[\udd00-\udd27]|\ud801[\udd30-\udd63]|\ud801\udd6f|\ud801[\ude00-\udf36]|\ud801[\udf40-\udf55]|\ud801[\udf60-\udf67]|\ud804\udc00|\ud804[\udc02-\udc37]|\ud804[\udc47-\udc4d]|\ud804[\udc66-\udc6f]|\ud804[\udc82-\udcb2]|\ud804\udcb7|\ud804\udcb8|\ud804[\udcbb-\udcc1]|\ud804[\udcd0-\udce8]|\ud804[\udcf0-\udcf9]|\ud804[\udd03-\udd26]|\ud804\udd2c|\ud804[\udd36-\udd43]|\ud804[\udd50-\udd72]|\ud804[\udd74-\udd76]|\ud804[\udd82-\uddb5]|\ud804[\uddbf-\uddc9]|\ud804\uddcd|\ud804[\uddd0-\udddf]|\ud804[\udde1-\uddf4]|\ud804[\ude00-\ude11]|\ud804[\ude13-\ude2e]|\ud804\ude32|\ud804\ude33|\ud804\ude35|\ud804[\ude38-\ude3d]|\ud804[\ude80-\ude86]|\ud804\ude88|\ud804[\ude8a-\ude8d]|\ud804[\ude8f-\ude9d]|\ud804[\ude9f-\udea9]|\ud804[\udeb0-\udede]|\ud804[\udee0-\udee2]|\ud804[\udef0-\udef9]|\ud804\udf02|\ud804\udf03|\ud804[\udf05-\udf0c]|\ud804\udf0f|\ud804\udf10|\ud804[\udf13-\udf28]|\ud804[\udf2a-\udf30]|\ud804\udf32|\ud804\udf33|\ud804[\udf35-\udf39]|\ud804[\udf3d-\udf3f]|\ud804[\udf41-\udf44]|\ud804\udf47|\ud804\udf48|\ud804[\udf4b-\udf4d]|\ud804\udf50|\ud804\udf57|\ud804[\udf5d-\udf63]|\ud805[\udc80-\udcb2]|\ud805\udcb9|\ud805[\udcbb-\udcbe]|\ud805\udcc1|\ud805[\udcc4-\udcc7]|\ud805[\udcd0-\udcd9]|\ud805[\udd80-\uddb1]|\ud805[\uddb8-\uddbb]|\ud805\uddbe|\ud805[\uddc1-\udddb]|\ud805[\ude00-\ude32]|\ud805\ude3b|\ud805\ude3c|\ud805\ude3e|\ud805[\ude41-\ude44]|\ud805[\ude50-\ude59]|\ud805[\ude80-\udeaa]|\ud805\udeac|\ud805\udeae|\ud805\udeaf|\ud805\udeb6|\ud805[\udec0-\udec9]|\ud805[\udf00-\udf19]|\ud805\udf20|\ud805\udf21|\ud805\udf26|\ud805[\udf30-\udf3f]|\ud806[\udca0-\udcf2]|\ud806\udcff|\ud806[\udec0-\udef8]|\ud808[\udc00-\udf99]|\ud809[\udc00-\udc6e]|\ud809[\udc70-\udc74]|\ud809[\udc80-\udd43]|\ud80c[\udc00-\udfff]|\ud80d[\udc00-\udc2e]|\ud811[\udc00-\ude46]|\ud81a[\udc00-\ude38]|\ud81a[\ude40-\ude5e]|\ud81a[\ude60-\ude69]|\ud81a\ude6e|\ud81a\ude6f|\ud81a[\uded0-\udeed]|\ud81a\udef5|\ud81a[\udf00-\udf2f]|\ud81a[\udf37-\udf45]|\ud81a[\udf50-\udf59]|\ud81a[\udf5b-\udf61]|\ud81a[\udf63-\udf77]|\ud81a[\udf7d-\udf8f]|\ud81b[\udf00-\udf44]|\ud81b[\udf50-\udf7e]|\ud81b[\udf93-\udf9f]|\ud82c\udc00|\ud82c\udc01|\ud82f[\udc00-\udc6a]|\ud82f[\udc70-\udc7c]|\ud82f[\udc80-\udc88]|\ud82f[\udc90-\udc99]|\ud82f\udc9c|\ud82f\udc9f|\ud834[\udc00-\udcf5]|\ud834[\udd00-\udd26]|\ud834[\udd29-\udd66]|\ud834[\udd6a-\udd72]|\ud834\udd83|\ud834\udd84|\ud834[\udd8c-\udda9]|\ud834[\uddae-\udde8]|\ud834[\udf60-\udf71]|\ud835[\udc00-\udc54]|\ud835[\udc56-\udc9c]|\ud835\udc9e|\ud835\udc9f|\ud835\udca2|\ud835\udca5|\ud835\udca6|\ud835[\udca9-\udcac]|\ud835[\udcae-\udcb9]|\ud835\udcbb|\ud835[\udcbd-\udcc3]|\ud835[\udcc5-\udd05]|\ud835[\udd07-\udd0a]|\ud835[\udd0d-\udd14]|\ud835[\udd16-\udd1c]|\ud835[\udd1e-\udd39]|\ud835[\udd3b-\udd3e]|\ud835[\udd40-\udd44]|\ud835\udd46|\ud835[\udd4a-\udd50]|\ud835[\udd52-\udea5]|\ud835[\udea8-\udeda]|\ud835[\udedc-\udf14]|\ud835[\udf16-\udf4e]|\ud835[\udf50-\udf88]|\ud835[\udf8a-\udfc2]|\ud835[\udfc4-\udfcb]|\ud836[\udc00-\uddff]|\ud836[\ude37-\ude3a]|\ud836[\ude6d-\ude74]|\ud836[\ude76-\ude83]|\ud836[\ude85-\ude8b]|\ud83c[\udd10-\udd2e]|\ud83c[\udd30-\udd69]|\ud83c[\udd70-\udd9a]|\ud83c[\udde6-\ude02]|\ud83c[\ude10-\ude3a]|\ud83c[\ude40-\ude48]|\ud83c\ude50|\ud83c\ude51|[\ud840-\ud868][\udc00-\udfff]|\ud869[\udc00-\uded6]|\ud869[\udf00-\udfff]|[\ud86a-\ud86c][\udc00-\udfff]|\ud86d[\udc00-\udf34]|\ud86d[\udf40-\udfff]|\ud86e[\udc00-\udc1d]|\ud86e[\udc20-\udfff]|[\ud86f-\ud872][\udc00-\udfff]|\ud873[\udc00-\udea1]|\ud87e[\udc00-\ude1d]|[\udb80-\udbbe][\udc00-\udfff]|\udbbf[\udc00-\udffd]|[\udbc0-\udbfe][\udc00-\udfff]|\udbff[\udc00-\udffd]' +
')|(' +
'[\u0590\u05be\u05c0\u05c3\u05c6\u05c8-\u05ff\u07c0-\u07ea\u07f4\u07f5\u07fa-\u0815\u081a\u0824\u0828\u082e-\u0858\u085c-\u089f\u200f\ufb1d\ufb1f-\ufb28\ufb2a-\ufb4f\u0608\u060b\u060d\u061b-\u064a\u066d-\u066f\u0671-\u06d5\u06e5\u06e6\u06ee\u06ef\u06fa-\u0710\u0712-\u072f\u074b-\u07a5\u07b1-\u07bf\u08a0-\u08e2\ufb50-\ufd3d\ufd40-\ufdcf\ufdf0-\ufdfc\ufdfe\ufdff\ufe70-\ufefe]|\ud802[\udc00-\udd1e]|\ud802[\udd20-\ude00]|\ud802\ude04|\ud802[\ude07-\ude0b]|\ud802[\ude10-\ude37]|\ud802[\ude3b-\ude3e]|\ud802[\ude40-\udee4]|\ud802[\udee7-\udf38]|\ud802[\udf40-\udfff]|\ud803[\udc00-\ude5f]|\ud803[\ude7f-\udfff]|\ud83a[\udc00-\udccf]|\ud83a[\udcd7-\udfff]|\ud83b[\udc00-\uddff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\udf00-\udfff]|\ud83b[\ude00-\udeef]|\ud83b[\udef2-\udeff]' +
')' +
')'
);
/**
* Gets directionality of the first strongly directional codepoint
*
* This is the rule the BIDI algorithm uses to determine the directionality of
* paragraphs ( http://unicode.org/reports/tr9/#The_Paragraph_Level ) and
* FSI isolates ( http://unicode.org/reports/tr9/#Explicit_Directional_Isolates ).
*
* TODO: Does not handle BIDI control characters inside the text.
* TODO: Does not handle unallocated characters.
*
* @ignore
* @param {string} text The text from which to extract initial directionality.
* @return {string|null} Directionality (either 'ltr' or 'rtl')
*/
function strongDirFromContent( text ) {
const m = text.match( strongDirRegExp );
if ( !m ) {
return null;
}
if ( m[ 2 ] === undefined ) {
return 'ltr';
}
return 'rtl';
}
// For everything in input that follows double-open-curly braces, there should be an equivalent parser
// function. For instance {{PLURAL ... }} will be processed by 'plural'.
// If you have 'magic words' then configure the parser to have them upon creation.
//
// An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
// Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
HtmlEmitter.prototype = {
/**
* Parsing has been applied depth-first we can assume that all nodes here are single nodes
* Must return a single node to parents -- a jQuery with synthetic span
* However, unwrap any other synthetic spans in our children and pass them upwards
*
* @param {any[]} nodes Some single nodes, some arrays of nodes
* @return {jQuery}
*/
concat: function ( nodes ) {
const $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
// Use Array.from since mixed parameter.
Array.from( nodes ).forEach( ( node ) => {
// Let jQuery append nodes, arrays of nodes and jQuery objects
// other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
appendWithoutParsing( $span, node );
} );
return $span;
},
/**
* Return escaped replacement of correct index, or string if unavailable.
* Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
* if the specified parameter is not found return the same string
* (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
*
* If the replacement at the index is an object, then a special property
* is is added to it (if it does not exist already).
* If the special property was already set, then we try to clone (instead of append)
* the replacement object. This allows allow using a jQuery or HTMLElement object
* multiple times within a single interface message.
*
* TODO: Throw error if nodes.length > 1 ?
*
* @param {Array} nodes List of one element, integer, n >= 0
* @param {Array} replacements List of at least n strings
* @return {string|jQuery} replacement
*/
replace: function ( nodes, replacements ) {
const index = parseInt( nodes[ 0 ], 10 );
if ( index < replacements.length ) {
if ( typeof replacements[ index ] === 'object' ) {
// Only actually clone on second use
if ( !replacements[ index ].mwJQueryMsgHasAlreadyBeenUsedAsAReplacement ) {
// Add our special property to the foreign object
// in the least invasive way
Object.defineProperty(
replacements[ index ],
'mwJQueryMsgHasAlreadyBeenUsedAsAReplacement',
{
value: true,
enumerable: false,
writable: false
}
);
return replacements[ index ];
}
if ( typeof replacements[ index ].clone === 'function' ) {
// if it is a jQuery object, use jQuery's clone method
return replacements[ index ].clone( true );
}
if ( typeof replacements[ index ].cloneNode === 'function' ) {
// if it is a Node, then use the native cloning functionality
return replacements[ index ].cloneNode( true );
}
return replacements[ index ];
}
return replacements[ index ];
} else {
// index not found, fallback to displaying variable
return '$' + ( index + 1 );
}
},
/**
* Transform wiki-link
*
* TODO:
* It only handles basic cases, either no pipe, or a pipe with an explicit
* anchor.
*
* It does not attempt to handle features like the pipe trick.
* However, the pipe trick should usually not be present in wikitext retrieved
* from the server, since the replacement is done at save time.
* It may, though, if the wikitext appears in extension-controlled content.
*
* @param {string[]} nodes
* @return {jQuery}
*/
wikilink: function ( nodes ) {
let page = textify( nodes[ 0 ] );
// Strip leading ':', which is used to suppress special behavior in wikitext links,
// e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
if ( page.charAt( 0 ) === ':' ) {
page = page.slice( 1 );
}
const title = new mw.Title( page );
let anchor;
if ( nodes.length === 1 ) {
// [[Some Page]] or [[Namespace:Some Page]]
anchor = page;
} else {
// [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
anchor = nodes[ 1 ];
}
const $el = $( '<a>' ).attr( {
title: title.getPrefixedText() || null,
href: title.getUrl()
} );
return appendWithoutParsing( $el, anchor );
},
/**
* Converts array of HTML element key value pairs to object
*
* @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
* name and 2 * n + 1 the associated value
* @return {Object} Object mapping attribute name to attribute value
*/
htmlattributes: function ( nodes ) {
const mapping = {};
for ( let i = 0, len = nodes.length; i < len; i += 2 ) {
mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
}
return mapping;
},
/**
* Handles an (already-validated) HTML element.
*
* @param {Array} nodes Nodes to process when creating element
* @return {jQuery}
*/
htmlelement: function ( nodes ) {
const tagName = nodes.shift();
const attributes = nodes.shift();
const contents = nodes;
const $element = $( document.createElement( tagName ) ).attr( attributes );
return appendWithoutParsing( $element, contents );
},
/**
* Transform parsed structure into external link.
*
* The "href" can be:
* - a jQuery object, treat it as "enclosing" the link text.
* - a function, treat it as the click handler.
* - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying.
*
* TODO: throw an error if nodes.length > 2 ?
*
* @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
* @return {jQuery}
*/
extlink: function ( nodes ) {
const arg = nodes[ 0 ],
contents = nodes[ 1 ];
let $el;
if ( arg instanceof $ && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
$el = arg;
} else {
$el = $( '<a>' );
if ( typeof arg === 'function' ) {
$el.attr( {
role: 'button',
tabindex: 0
} ).on( 'click keypress', function ( e ) {
if (
e.type === 'click' ||
e.type === 'keypress' && e.which === 13
) {
arg.call( this, e );
}
} );
} else {
const target = textify( arg );
if ( target.search( new RegExp( '^(/|' + mw.config.get( 'wgUrlProtocols' ) + ')' ) ) !== -1 ) {
$el.attr( 'href', target );
if ( target.search( '^' + mw.config.get( 'wgArticlePath' ).replace( /\$1/g, '.+?' ) + '$' ) === -1 ) {
$el.addClass( 'external' );
}
} else {
mw.log( 'External link in message had illegal target ' + target );
return appendWithoutParsing(
$( '<span>' ),
[ '[' + target + ' ' ].concat( contents ).concat( ']' )
).contents();
}
}
}
return appendWithoutParsing( $el.empty(), contents );
},
/**
* Transform formal syntax
*
* @param {string[]} nodes List of nodes
* @return {string|jQuery} selected (in)formal form according to the current language
*/
'#formal': function ( nodes ) {
const formalityIndex = this.language.getData(
mw.config.get( 'wgUserLanguage' ),
'formalityIndex'
);
if ( nodes.length === 0 ) {
return '';
} else if ( nodes.length === 1 ) {
return nodes[ 0 ];
}
return nodes[ formalityIndex ];
},
/**
* Transform parsed structure into pluralization
* n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
* So convert it back with the current language's convertNumber.
*
* @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
* @return {string|jQuery} selected pluralized form according to current language
*/
plural: function ( nodes ) {
const explicitPluralForms = {};
const count = parseFloat( this.language.convertNumber( textify( nodes[ 0 ] ), true ) );
let forms = nodes.slice( 1 );
for ( let formIndex = 0; formIndex < forms.length; formIndex++ ) {
const form = forms[ formIndex ];
if ( form instanceof $ && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
// This is a nested node, may be an explicit plural form like 5=[$2 linktext]
const firstChild = form.contents().get( 0 );
if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
const firstChildText = firstChild.textContent;
if ( /^\d+=/.test( firstChildText ) ) {
const explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
// Use the digit part as key and rest of first text node and
// rest of child nodes as value.
firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
explicitPluralForms[ explicitPluralFormNumber ] = form;
forms[ formIndex ] = undefined;
}
}
} else if ( /^\d+=/.test( form ) ) {
// Simple explicit plural forms like 12=a dozen
const explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
forms[ formIndex ] = undefined;
}
}
// Remove explicit plural forms from the forms. They were set undefined in the above loop.
// eslint-disable-next-line no-jquery/no-map-util
forms = $.map( forms, ( f ) => f );
return this.language.convertPlural( count, forms, explicitPluralForms );
},
/**
* Transform parsed structure according to gender.
*
* The first node must be one of:
* - the mw.user object (or a compatible one)
* - an empty string - indicating the current user, same effect as passing the mw.user object
* - a gender string ('male', 'female' or 'unknown')
*
* @example
* {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}
*
* @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
* @return {string|jQuery} Selected gender form according to current language
*/
gender: function ( nodes ) {
const forms = nodes.slice( 1 );
let maybeUser = nodes[ 0 ];
if ( maybeUser === '' ) {
maybeUser = mw.user;
}
let gender;
// If we are passed a mw.user-like object, check their gender.
// Otherwise, assume the gender string itself was passed .
if ( maybeUser && maybeUser.options instanceof mw.Map ) {
gender = maybeUser.options.get( 'gender' );
} else {
gender = textify( maybeUser );
}
return this.language.gender( gender, forms );
},
/**
* Wraps argument with unicode control characters for directionality safety
*
* Identical to the implementation in jquery.i18n.emitter.bidi.js
*
* This solves the problem where directionality-neutral characters at the edge of
* the argument string get interpreted with the wrong directionality from the
* enclosing context, giving renderings that look corrupted like "(Ben_(WMF".
*
* The wrapping is LRE...PDF or RLE...PDF, depending on the detected
* directionality of the argument string, using the BIDI algorithm's own "First
* strong directional codepoint" rule. Essentially, this works round the fact that
* there is no embedding equivalent of U+2068 FSI (isolation with heuristic
* direction inference). The latter is cleaner but still not widely supported.
*
* @param {string[]} nodes The text nodes from which to take the first item.
* @return {string} Wrapped String of content as needed.
*/
bidi: function ( nodes ) {
const dir = strongDirFromContent( nodes[ 0 ] );
if ( dir === 'ltr' ) {
// Wrap in LEFT-TO-RIGHT EMBEDDING ... POP DIRECTIONAL FORMATTING
return '\u202A' + nodes[ 0 ] + '\u202C';
}
if ( dir === 'rtl' ) {
// Wrap in RIGHT-TO-LEFT EMBEDDING ... POP DIRECTIONAL FORMATTING
return '\u202B' + nodes[ 0 ] + '\u202C';
}
// No strong directionality: do not wrap
return nodes[ 0 ];
},
/**
* Transform parsed structure into grammar conversion.
* Invoked by putting `{{grammar:form|word}}` in a message
*
* @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
* @return {string|jQuery} selected grammatical form according to current language
*/
grammar: function ( nodes ) {
const form = nodes[ 0 ],
word = nodes[ 1 ];
// These could be jQuery objects (passed as message parameters),
// in which case we can't transform them (like rawParams() in PHP).
if ( typeof form === 'string' && typeof word === 'string' ) {
return this.language.convertGrammar( word, form );
}
return word;
},
/**
* Transform parsed structure into a int: (interface language) message include
* Invoked by putting `{{int:othermessage}}` into a message
*
* TODO Syntax in the included message is not parsed, this seems like a bug?
*
* @param {Array} nodes List of nodes
* @return {string} Other message
*/
int: function ( nodes ) {
const msg = textify( nodes[ 0 ] );
return getMessageFunction()( mwString.lcFirst( msg ) );
},
/**
* Get localized namespace name from canonical name or namespace number.
* Invoked by putting `{{ns:foo}}` into a message
*
* @param {Array} nodes List of nodes
* @return {string} Localized namespace name
*/
ns: function ( nodes ) {
let ns = textify( nodes[ 0 ] ).trim();
if ( !/^\d+$/.test( ns ) ) {
ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
}
ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
return ns || '';
},
/**
* Takes an unformatted number (arab, no group separators and . as decimal separator)
* and outputs it in the localized digit script and formatted with decimal
* separator, according to the current language.
*
* @param {Array} nodes List of nodes
* @return {number|string|jQuery} Formatted number
*/
formatnum: function ( nodes ) {
const isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
number = nodes[ 0 ];
// These could be jQuery objects (passed as message parameters),
// in which case we can't transform them (like rawParams() in PHP).
if ( typeof number === 'string' || typeof number === 'number' ) {
return this.language.convertNumber( number, isInteger );
}
return number;
},
/**
* Takes a pagename and optional URL queries and returns a full URL to that
* page (with URL queries).
*
* @param {Array} nodes List of nodes
* @return {string} A URL string
*/
fullurl: function ( nodes ) {
const targetPage = textify( nodes[ 0 ] ),
queryObject = {};
let queryStrings = nodes[ 1 ];
if ( queryStrings ) {
queryStrings = textify( queryStrings );
queryStrings = new URLSearchParams( queryStrings );
for ( const [ key, value ] of queryStrings.entries() ) {
queryObject[ key ] = value;
}
}
return mw.config.get( 'wgServer' ) + util.getUrl( targetPage, queryObject );
},
/**
* Lowercase text
*
* @param {Array} nodes List of nodes
* @return {string} The given text, all in lowercase
*/
lc: function ( nodes ) {
return textify( nodes[ 0 ] ).toLowerCase();
},
/**
* Uppercase text
*
* @param {Array} nodes List of nodes
* @return {string} The given text, all in uppercase
*/
uc: function ( nodes ) {
return textify( nodes[ 0 ] ).toUpperCase();
},
/**
* Lowercase first letter of input, leaving the rest unchanged
*
* @param {Array} nodes List of nodes
* @return {string} The given text, with the first character in lowercase
*/
lcfirst: function ( nodes ) {
const text = textify( nodes[ 0 ] );
return mwString.lcFirst( text );
},
/**
* Uppercase first letter of input, leaving the rest unchanged
*
* @param {Array} nodes List of nodes
* @return {string} The given text, with the first character in uppercase
*/
ucfirst: function ( nodes ) {
const text = textify( nodes[ 0 ] );
return mwString.ucFirst( text );
}
};
/**
* Provides a {@link jQuery} plugin that parses messages.
*
* @module mediawiki.jqueryMsg
*/
/**
* Parses the message in the message key, doing replacements optionally, and appends the nodes to
* the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
*
* To use this {@link jQuery} plugin, load the `mediawiki.jqueryMsg` module with {@link mw.loader}.
*
* @memberof module:mediawiki.jqueryMsg
* @param {string} message key
* @param {...string[]} arguments
* @example
* mw.loader.using('mediawiki.jqueryMsg' ).then(() => {
* var $userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
* $( 'p#headline' ).msg( 'hello-user', $userlink );
* } );
*
* // N.B. replacements are variadic arguments or an array in second parameter. In other words:
* somefunction( a, b, c, d )
* // is equivalent to
* somefunction( a, [b, c, d] )
*
* // Note: We append to 'this', which in a jQuery plugin context will be the selected elements.
*/
$.fn.msg = getPlugin();
// Replace the default message parser with jqueryMsg
const oldParser = mw.Message.prototype.parser;
mw.Message.prototype.parser = function ( format ) {
// Fall back to mw.msg's simple parser where possible
if (
// Plain text output always uses the simple parser
format === 'plain' ||
(
// jqueryMsg parser is needed for messages containing wikitext
!/\{\{|[<>[&]/.test( this.map.get( this.key ) ) &&
// jqueryMsg parser is needed when jQuery objects or DOM nodes are passed in as parameters
!this.parameters.some( ( param ) => param instanceof $ || ( param && param.nodeType !== undefined ) )
)
) {
return oldParser.call( this, format );
}
if ( !Object.prototype.hasOwnProperty.call( this.map, format ) ) {
this.map[ format ] = getMessageFunction( {
messages: this.map,
// For format 'escaped', escaping part is handled by mediawiki.js
format: format
} );
}
return this.map[ format ]( this.key, this.parameters );
};
/**
* Parse the message to DOM nodes, rather than HTML string like {@link mw.Message#parse}.
*
* This method is only available when jqueryMsg is loaded.
*
* @example
* const msg = mw.message( 'key' );
* mw.loader.using(`mediawiki.jqueryMsg`).then(() => {
* if ( msg.isParseable() ) {
* const $node = msg.parseDom();
* $node.appendTo('body');
* }
* })
*
* @since 1.27
* @method parseDom
* @memberof mw.Message.prototype
* @return {jQuery}
*/
mw.Message.prototype.parseDom = ( function () {
let failableParserFn;
return function () {
if ( !failableParserFn ) {
failableParserFn = getFailableParserFn();
}
const $result = failableParserFn( [ this.key, this.parameters ] );
return $result.contents();
};
}() );
/**
* Check whether the message contains only syntax supported by jqueryMsg.
*
* This method is only available when jqueryMsg is loaded.
*
* @example
* const msg = mw.message( 'key' );
* mw.loader.using(`mediawiki.jqueryMsg`).then(() => {
* if ( msg.isParseable() ) {
* ...
* }
* })
*
* @since 1.41
* @method isParseable
* @memberof mw.Message.prototype
* @return {boolean}
*/
mw.Message.prototype.isParseable = function () {
const parser = new Parser();
try {
parser.parse( this.key, this.parameters );
return true;
} catch ( e ) {
return false;
}
};
/**
* Can be deleted when MobileFrontend is updated.
* https://phabricator.wikimedia.org/T354540
*
* @private
*/
mw.jqueryMsg = {
Parser
};
mw.log.deprecate( mw, 'jqueryMsg', mw.jqueryMsg, 'mw.jqueryMsg is a @private library.' );
// Expose for testing purposes only (not a stable API).
module.exports = {
test: {
getMessageFunction,
setMessageFunction,
getParserDefaults,
setParserDefaults,
Parser
}
};