*
* @note As of 1.21, XmlJsCode objects cannot be nested inside objects or arrays. The sole
* exception is the $args argument to Xml::encodeJsCall() because Xml::encodeJsVar() is
- * called for each individual element in that array.
+ * called for each individual element in that array. If you need to encode an object or array
+ * containing XmlJsCode objects, use XmlJsCode::encodeObject() to re-encode it first.
*
* @since 1.17
*/
function __construct( $value ) {
$this->value = $value;
}
+
+ /**
+ * Encode an object containing XmlJsCode objects.
+ *
+ * This takes an object or associative array where (some of) the values are XmlJsCode objects,
+ * and re-encodes it as a single XmlJsCode object.
+ *
+ * @since 1.33
+ * @param object|array $obj Object or associative array to encode
+ * @param bool $pretty If true, add non-significant whitespace to improve readability.
+ * @return XmlJsCode
+ */
+ public static function encodeObject( $obj, $pretty = false ) {
+ $parts = [];
+ foreach ( $obj as $key => $value ) {
+ $parts[] =
+ ( $pretty ? ' ' : '' ) .
+ Xml::encodeJsVar( $key, $pretty ) .
+ ( $pretty ? ': ' : ':' ) .
+ Xml::encodeJsVar( $value, $pretty );
+ }
+ return new self(
+ '{' .
+ ( $pretty ? "\n" : '' ) .
+ implode( $pretty ? ",\n" : ',', $parts ) .
+ ( $pretty ? "\n" : '' ) .
+ '}'
+ );
+ }
}
// Load scripts raw...
$strContent = $scripts;
} elseif ( is_array( $scripts ) ) {
- // ...except when $scripts is an array of URLs
+ // ...except when $scripts is an array of URLs or an associative array
$strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
}
break;
*
* @param string $name Module name or implement key (format "`[name]@[version]`")
* @param XmlJsCode|array|string $scripts Code as XmlJsCode (to be wrapped in a closure),
- * list of URLs to JavaScript files, or a string of JavaScript for `$.globalEval`.
+ * list of URLs to JavaScript files, string of JavaScript for `$.globalEval`, or array with
+ * 'files' and 'main' properties (see ResourceLoaderModule::getScript())
* @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs
* to CSS files keyed by media type
* @param mixed $messages List of messages associated with this module. May either be an
} else {
$scripts = new XmlJsCode( 'function($,jQuery,require,module){' . $scripts->value . '}' );
}
+ } elseif ( is_array( $scripts ) && isset( $scripts['files'] ) ) {
+ $files = $scripts['files'];
+ foreach ( $files as $path => &$file ) {
+ // $file is changed (by reference) from a descriptor array to the content of the file
+ // All of these essentially do $file = $file['content'];, some just have wrapping around it
+ if ( $file['type'] === 'script' ) {
+ // Multi-file modules only get two parameters ($ and jQuery are being phased out)
+ if ( self::inDebugMode() ) {
+ $file = new XmlJsCode( "function ( require, module ) {\n{$file['content']}\n}" );
+ } else {
+ $file = new XmlJsCode( 'function(require,module){' . $file['content'] . '}' );
+ }
+ } else {
+ $file = $file['content'];
+ }
+ }
+ $scripts = XmlJsCode::encodeObject( [
+ 'main' => $scripts['main'],
+ 'files' => XmlJsCode::encodeObject( $files, self::inDebugMode() )
+ ], self::inDebugMode() );
} elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
}
+
// mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
// arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
// of "{}". Force them to objects.
$scripts,
(object)$styles,
(object)$messages,
- (object)$templates,
+ (object)$templates
];
self::trimArray( $module );
*/
protected $skinStyles = [];
+ /**
+ * @var array List of packaged files to make available through require()
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $packageFiles = null;
+
+ /**
+ * @var array Expanded versions of $packageFiles, lazy-computed by expandPackageFiles();
+ * keyed by context hash
+ */
+ private $expandedPackageFiles = [];
+
/**
* @var array List of modules this module depends on
* @par Usage:
* 'remoteExtPath' => [base path],
* // Equivalent of remoteBasePath, but relative to $wgStylePath
* 'remoteSkinPath' => [base path],
- * // Scripts to always include
+ * // Scripts to always include (cannot be set if 'packageFiles' is also set, see below)
* 'scripts' => [file path string or array of file path strings],
* // Scripts to include in specific language contexts
* 'languageScripts' => [
* ],
* // Scripts to include in debug contexts
* 'debugScripts' => [file path string or array of file path strings],
+ * // For package modules: files to make available for internal require() use
+ * // 'type' is optional, and will be inferred from the file name extension if omitted
+ * // 'config' can only be used when 'type' is 'data'; vars are resolved with Config::get()
+ * // If 'packageFiles' is set, 'scripts' cannot also be set
+ * 'packageFiles' => [
+ * [file path string], // or:
+ * [file alias] => [file path string], // or:
+ * [file alias] => [ 'file' => [file path string], 'type' => 'script'|'data' ], // or:
+ * [file alias] => [ 'content' => [string], 'type' => 'script'|'data' ], // or:
+ * [file alias] => [ 'callback' => [callable], 'type' => 'script'|'data' ], // or:
+ * [file alias] => [ 'config' => [ [config var name], ... ], 'type' => 'data' ], // or:
+ * [file alias] => [ 'config' => [ [JS name] => [PHP name] ], 'type' => 'data' ],
+ * ],
* // Modules which must be loaded before this module
* 'dependencies' => [module name string or array of module name strings],
* 'templates' => [
case 'scripts':
case 'debugScripts':
case 'styles':
+ case 'packageFiles':
$this->{$member} = (array)$option;
break;
case 'templates':
break;
}
}
+ if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) {
+ throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" );
+ }
if ( $hasTemplates ) {
$this->dependencies[] = 'mediawiki.template';
// Ensure relevant template compiler module gets loaded
* Gets all scripts for a given context concatenated together.
*
* @param ResourceLoaderContext $context Context in which to generate script
- * @return string JavaScript code for $context
+ * @return string|array JavaScript code for $context, or package files data structure
*/
public function getScript( ResourceLoaderContext $context ) {
+ $deprecationScript = $this->getDeprecationInformation();
+ if ( $this->packageFiles !== null ) {
+ $packageFiles = $this->getPackageFiles( $context );
+ if ( $deprecationScript ) {
+ $mainFile =& $packageFiles['files'][ $packageFiles['main'] ];
+ $mainFile['content'] = $deprecationScript . $mainFile['content'];
+ }
+ return $packageFiles;
+ }
+
$files = $this->getScriptFiles( $context );
- return $this->getDeprecationInformation() . $this->readScriptFiles( $files );
+ return $deprecationScript . $this->readScriptFiles( $files );
}
/**
* @return bool
*/
public function supportsURLLoading() {
- return $this->debugRaw;
+ // If package files are involved, don't support URL loading, because that breaks
+ // scoped require() functions
+ return $this->debugRaw && !$this->packageFiles;
}
/**
$files = array_merge( $files, $styleFiles );
}
+ // Extract file names for package files
+ $expandedPackageFiles = $this->expandPackageFiles( $context );
+ $packageFiles = $expandedPackageFiles ?
+ array_filter( array_map( function ( $fileInfo ) {
+ return $fileInfo['filePath'] ?? null;
+ }, $expandedPackageFiles['files'] ) ) :
+ [];
+
// Final merge, this should result in a master list of dependent files
$files = array_merge(
$files,
+ $packageFiles,
$this->scripts,
$this->templates,
$context->getDebug() ? $this->debugScripts : [],
$summary[] = [
'options' => $options,
+ 'packageFiles' => $this->expandPackageFiles( $context ),
'fileHashes' => $this->getFileHashes( $context ),
'messageBlob' => $this->getMessageBlob( $context ),
];
return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
}
+ /**
+ * Infer the file type from a package file path.
+ * @param string $path
+ * @return string 'script' or 'data'
+ */
+ public static function getPackageFileType( $path ) {
+ if ( preg_match( '/\.json$/i', $path ) ) {
+ return 'data';
+ }
+ return 'script';
+ }
+
/**
* Collates file paths by option (where provided).
*
* Get the contents of a list of JavaScript files. Helper for getScript().
*
* @param array $scripts List of file paths to scripts to read, remap and concetenate
- * @return string Concatenated and remapped JavaScript data from $scripts
+ * @return string Concatenated JavaScript data from $scripts
* @throws MWException
*/
private function readScriptFiles( array $scripts ) {
return $templates;
}
+ /**
+ * Expand the packageFiles definition into something that's (almost) the right format for
+ * getPackageFiles() to return. This expands shorthands, resolves config vars and callbacks,
+ * but does not expand file paths or read the actual contents of files. Those things are done
+ * by getPackageFiles().
+ *
+ * This is split up in this way so that getFileHashes() can get a list of file names, and
+ * getDefinitionSummary() can get config vars and callback results in their expanded form.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array|null
+ */
+ private function expandPackageFiles( ResourceLoaderContext $context ) {
+ $hash = $context->getHash();
+ if ( isset( $this->expandedPackageFiles[$hash] ) ) {
+ return $this->expandedPackageFiles[$hash];
+ }
+ if ( $this->packageFiles === null ) {
+ return null;
+ }
+ $expandedFiles = [];
+ $mainFile = null;
+
+ foreach ( $this->packageFiles as $alias => $fileInfo ) {
+ // Alias is optional, but only when specfiying plain file names (strings)
+ if ( is_int( $alias ) ) {
+ if ( is_array( $fileInfo ) ) {
+ $msg = __METHOD__ . ": invalid package file definition for module " .
+ "\"{$this->getName()}\": key is required when value is not a string";
+ wfDebugLog( 'resourceloader', $msg );
+ throw new MWException( $msg );
+ }
+ $alias = $fileInfo;
+ }
+ if ( !is_array( $fileInfo ) ) {
+ $fileInfo = [ 'file' => $fileInfo ];
+ }
+
+ // Infer type from alias if needed
+ $type = $fileInfo['type'] ?? self::getPackageFileType( $alias );
+ $expanded = [ 'type' => $type ];
+ if ( !empty( $fileInfo['main'] ) ) {
+ $mainFile = $alias;
+ if ( $type !== 'script' ) {
+ $msg = __METHOD__ . ": invalid package file definition for module " .
+ "\"{$this->getName()}\": main file \"$mainFile\" must be of type \"script\", not \"$type\"";
+ wfDebugLog( 'resourceloader', $msg );
+ throw new MWException( $msg );
+ }
+ }
+
+ if ( isset( $fileInfo['content'] ) ) {
+ $expanded['content'] = $fileInfo['content'];
+ } elseif ( isset( $fileInfo['file'] ) ) {
+ $expanded['filePath'] = $fileInfo['file'];
+ } elseif ( isset( $fileInfo['callback'] ) ) {
+ if ( is_callable( $fileInfo['callback'] ) ) {
+ $expanded['content'] = $fileInfo['callback']( $context );
+ } else {
+ $msg = __METHOD__ . ": invalid callback for package file \"$alias\"" .
+ " in module \"{$this->getName()}\"";
+ wfDebugLog( 'resourceloader', $msg );
+ throw new MWException( $msg );
+ }
+ } elseif ( isset( $fileInfo['config'] ) ) {
+ if ( $type !== 'data' ) {
+ $msg = __METHOD__ . ": invalid use of \"config\" for package file \"$alias\" in module " .
+ "\"{$this->getName()}\": type must be \"data\" but is \"$type\"";
+ wfDebugLog( 'resourceloader', $msg );
+ throw new MWException( $msg );
+ }
+ $expandedConfig = [];
+ foreach ( $fileInfo['config'] as $key => $var ) {
+ $expandedConfig[ is_numeric( $key ) ? $var : $key ] = $this->getConfig()->get( $var );
+ }
+ $expanded['content'] = $expandedConfig;
+ } elseif ( !empty( $fileInfo['main'] ) ) {
+ // 'foo.js' => [ 'main' => true ] is shorthand
+ $expanded['filePath'] = $alias;
+ } else {
+ $msg = __METHOD__ . ": invalid package file definition for \"$alias\" in module " .
+ "\"{$this->getName()}\": one of \"file\", \"content\", \"callback\" or \"config\" must be set";
+ wfDebugLog( 'resourceloader', $msg );
+ throw new MWException( $msg );
+ }
+
+ $expandedFiles[$alias] = $expanded;
+ }
+
+ if ( $expandedFiles && $mainFile === null ) {
+ // The first package file that is a script is the main file
+ foreach ( $expandedFiles as $path => &$file ) {
+ if ( $file['type'] === 'script' ) {
+ $mainFile = $path;
+ break;
+ }
+ }
+ }
+
+ $result = [
+ 'main' => $mainFile,
+ 'files' => $expandedFiles
+ ];
+
+ $this->expandedPackageFiles[$hash] = $result;
+ return $result;
+ }
+
+ /**
+ * Resolves the package files defintion and generates the content of each package file.
+ * @param ResourceLoaderContext $context
+ * @return array Package files data structure, see ResourceLoaderModule::getScript()
+ */
+ public function getPackageFiles( ResourceLoaderContext $context ) {
+ if ( $this->packageFiles === null ) {
+ return null;
+ }
+ $expandedPackageFiles = $this->expandPackageFiles( $context );
+
+ // Expand file contents
+ foreach ( $expandedPackageFiles['files'] as &$fileInfo ) {
+ if ( isset( $fileInfo['filePath'] ) ) {
+ $localPath = $this->getLocalPath( $fileInfo['filePath'] );
+ if ( !file_exists( $localPath ) ) {
+ $msg = __METHOD__ . ": package file not found: \"$localPath\"" .
+ " in module \"{$this->getName()}\"";
+ wfDebugLog( 'resourceloader', $msg );
+ throw new MWException( $msg );
+ }
+ $content = $this->stripBom( file_get_contents( $localPath ) );
+ if ( $fileInfo['type'] === 'data' ) {
+ $content = json_decode( $content );
+ }
+ $fileInfo['content'] = $content;
+ unset( $fileInfo['filePath'] );
+ }
+ }
+
+ return $expandedPackageFiles;
+ }
+
/**
* Takes an input string and removes the UTF-8 BOM character if present
*
* Get all JS for this module for a given language and skin.
* Includes all relevant JS except loader scripts.
*
+ * For "plain" script modules, this should return a string with JS code. For multi-file modules
+ * where require() is used to load one file from another file, this should return an array
+ * structured as follows:
+ * [
+ * 'files' => [
+ * 'file1.js' => [ 'type' => 'script', 'content' => 'JS code' ],
+ * 'file2.js' => [ 'type' => 'script', 'content' => 'JS code' ],
+ * 'data.json' => [ 'type' => 'data', 'content' => array ]
+ * ],
+ * 'main' => 'file1.js'
+ * ]
+ *
* @param ResourceLoaderContext $context
- * @return string JavaScript code
+ * @return string|array JavaScript code (string), or multi-file structure described above (array)
*/
public function getScript( ResourceLoaderContext $context ) {
// Stub, override expected
// This MUST build both scripts and styles, regardless of whether $context->getOnly()
// is 'scripts' or 'styles' because the result is used by getVersionHash which
- // must be consistent regardles of the 'only' filter on the current request.
+ // must be consistent regardless of the 'only' filter on the current request.
// Also, when introducing new module content resources (e.g. templates, headers),
// these should only be included in the array when they are non-empty so that
// existing modules not using them do not get their cache invalidated.
'use strict';
var mw, StringSet, log,
+ hasOwn = Object.prototype.hasOwnProperty,
trackQueue = [];
/**
return resolved;
}
+ /**
+ * Resolve a relative file path.
+ *
+ * For example, resolveRelativePath( '../foo.js', 'resources/src/bar/bar.js' )
+ * returns 'resources/src/foo.js'.
+ *
+ * @param {string} relativePath Relative file path, starting with ./ or ../
+ * @param {string} basePath Path of the file (not directory) relativePath is relative to
+ * @return {string|null} Resolved path, or null if relativePath does not start with ./ or ../
+ */
+ function resolveRelativePath( relativePath, basePath ) {
+ var prefixes, prefix, baseDirParts,
+ relParts = relativePath.match( /^((?:\.\.?\/)+)(.*)$/ );
+
+ if ( !relParts ) {
+ return null;
+ }
+
+ baseDirParts = basePath.split( '/' );
+ // basePath looks like 'foo/bar/baz.js', so baseDirParts looks like [ 'foo', 'bar, 'baz.js' ]
+ // Remove the file component at the end, so that we are left with only the directory path
+ baseDirParts.pop();
+
+ prefixes = relParts[ 1 ].split( '/' );
+ // relParts[ 1 ] looks like '../../', so prefixes looks like [ '..', '..', '' ]
+ // Remove the empty element at the end
+ prefixes.pop();
+
+ // For every ../ in the path prefix, remove one directory level from baseDirParts
+ while ( ( prefix = prefixes.pop() ) !== undefined ) {
+ if ( prefix === '..' ) {
+ baseDirParts.pop();
+ }
+ }
+
+ // If there's anything left of the base path, prepend it to the file path
+ return ( baseDirParts.length ? baseDirParts.join( '/' ) + '/' : '' ) + relParts[ 2 ];
+ }
+
+ /**
+ * Make a require() function scoped to a package file
+ * @private
+ * @param {Object} moduleObj Module object from the registry
+ * @param {string} basePath Path of the file this is scoped to. Used for relative paths.
+ * @return {Function}
+ */
+ function makeRequireFunction( moduleObj, basePath ) {
+ return function require( moduleName ) {
+ var fileName, fileContent, result, moduleParam,
+ scriptFiles = moduleObj.script.files;
+ fileName = resolveRelativePath( moduleName, basePath );
+ if ( fileName === null ) {
+ // Not a relative path, so it's a module name
+ return mw.loader.require( moduleName );
+ }
+
+ if ( !hasOwn.call( scriptFiles, fileName ) ) {
+ throw new Error( 'Cannot require() undefined file ' + fileName );
+ }
+ if ( hasOwn.call( moduleObj.packageExports, fileName ) ) {
+ // File has already been executed, return the cached result
+ return moduleObj.packageExports[ fileName ];
+ }
+
+ fileContent = scriptFiles[ fileName ];
+ if ( typeof fileContent === 'function' ) {
+ moduleParam = { exports: {} };
+ fileContent( makeRequireFunction( moduleObj, fileName ), moduleParam );
+ result = moduleParam.exports;
+ } else {
+ // fileContent is raw data, just pass it through
+ result = fileContent;
+ }
+ moduleObj.packageExports[ fileName ] = result;
+ return result;
+ };
+ }
+
/**
* Load and execute a script.
*
$CODE.profileExecuteStart();
runScript = function () {
- var script, markModuleReady, nestedAddScript;
+ var script, markModuleReady, nestedAddScript, mainScript;
$CODE.profileScriptStart();
script = registry[ module ].script;
try {
if ( Array.isArray( script ) ) {
nestedAddScript( script, markModuleReady, 0 );
- } else if ( typeof script === 'function' ) {
- // Keep in sync with queueModuleScript() for debug mode
- if ( module === 'jquery' ) {
- // This is a special case for when 'jquery' itself is being loaded.
- // - The standard jquery.js distribution does not set `window.jQuery`
- // in CommonJS-compatible environments (Node.js, AMD, RequireJS, etc.).
- // - MediaWiki's 'jquery' module also bundles jquery.migrate.js, which
- // in a CommonJS-compatible environment, will use require('jquery'),
- // but that can't work when we're still inside that module.
- script();
+ } else if (
+ typeof script === 'function' || (
+ typeof script === 'object' &&
+ script !== null
+ )
+ ) {
+ if ( typeof script === 'function' ) {
+ // Keep in sync with queueModuleScript() for debug mode
+ if ( module === 'jquery' ) {
+ // This is a special case for when 'jquery' itself is being loaded.
+ // - The standard jquery.js distribution does not set `window.jQuery`
+ // in CommonJS-compatible environments (Node.js, AMD, RequireJS, etc.).
+ // - MediaWiki's 'jquery' module also bundles jquery.migrate.js, which
+ // in a CommonJS-compatible environment, will use require('jquery'),
+ // but that can't work when we're still inside that module.
+ script();
+ } else {
+ // Pass jQuery twice so that the signature of the closure which wraps
+ // the script can bind both '$' and 'jQuery'.
+ script( window.$, window.$, mw.loader.require, registry[ module ].module );
+ }
} else {
- // Pass jQuery twice so that the signature of the closure which wraps
- // the script can bind both '$' and 'jQuery'.
- script( window.$, window.$, mw.loader.require, registry[ module ].module );
+ mainScript = script.files[ script.main ];
+ if ( typeof mainScript !== 'function' ) {
+ throw new Error( 'Main script file ' + script.main + ' in module ' + module +
+ 'must be of type function, is of type ' + typeof mainScript );
+ }
+ // jQuery parameters are not passed for multi-file modules
+ mainScript(
+ makeRequireFunction( registry[ module ], script.main ),
+ registry[ module ].module
+ );
}
markModuleReady();
-
} else if ( typeof script === 'string' ) {
// Site and user modules are legacy scripts that run in the global scope.
// This is transported as a string instead of a function to avoid needing
module: {
exports: {}
},
+ // module.export objects for each package file inside this module
+ packageExports: {},
version: String( version || '' ),
dependencies: dependencies || [],
group: typeof group === 'string' ? group : null,
* as '`[name]@[version]`". This version should match the requested version
* (from #batchRequest and #registry). This avoids race conditions (T117587).
* For back-compat with MediaWiki 1.27 and earlier, the version may be omitted.
- * @param {Function|Array|string} [script] Function with module code, list of URLs
- * to load via `<script src>`, or string of module code for `$.globalEval()`.
+ * @param {Function|Array|string|Object} [script] Module code. This can be a function,
+ * a list of URLs to load via `<script src>`, a string for `$.globalEval()`, or an
+ * object like {"files": {"foo.js":function, "bar.js": function, ...}, "main": "foo.js"}.
+ * If an object is provided, the main file will be executed immediately, and the other
+ * files will only be executed if loaded via require(). If a function or string is
+ * provided, it will be executed/evaluated immediately. If an array is provided, all
+ * URLs in the array will be loaded immediately, and executed as soon as they arrive.
* @param {Object} [style] Should follow one of the following patterns:
*
* { "css": [css, ..] }
*/
set: function ( module ) {
var key, args, src,
+ encodedScript,
descriptor = mw.loader.moduleRegistry[ module ];
key = getModuleKey( module );
}
try {
+ if ( typeof descriptor.script === 'function' ) {
+ encodedScript = String( descriptor.script );
+ } else if (
+ // Plain object: an object that is not null and is not an array
+ typeof descriptor.script === 'object' &&
+ descriptor.script &&
+ !Array.isArray( descriptor.script )
+ ) {
+ encodedScript = '{' +
+ Object.keys( descriptor.script ).map( function ( key ) {
+ var value = descriptor.script[ key ];
+ return JSON.stringify( key ) + ':' +
+ ( typeof value === 'function' ? value : JSON.stringify( value ) );
+ } ).join( ',' ) +
+ '}';
+ } else {
+ encodedScript = JSON.stringify( descriptor.script );
+ }
args = [
JSON.stringify( key ),
- typeof descriptor.script === 'function' ?
- String( descriptor.script ) :
- JSON.stringify( descriptor.script ),
+ encodedScript,
JSON.stringify( descriptor.style ),
JSON.stringify( descriptor.messages ),
JSON.stringify( descriptor.templates )
--- /dev/null
+{
+ "foo": "bar",
+ "answer": 42
+}
);
}
+ /**
+ * @covers Xml::encodeJsVar
+ */
+ public function testXmlJsCode() {
+ $code = 'function () { foo( 42 ); }';
+ $this->assertEquals(
+ $code,
+ Xml::encodeJsVar( new XmlJsCode( $code ) )
+ );
+ }
+
+ /**
+ * @covers Xml::encodeJsVar
+ * @covers XmlJsCode::encodeObject
+ */
+ public function testEncodeObject() {
+ $codeA = 'function () { foo( 42 ); }';
+ $codeB = 'function ( jQuery ) { bar( 142857 ); }';
+ $obj = XmlJsCode::encodeObject( [
+ 'a' => new XmlJsCode( $codeA ),
+ 'b' => new XmlJsCode( $codeB )
+ ] );
+ $this->assertEquals(
+ "{\"a\":$codeA,\"b\":$codeB}",
+ Xml::encodeJsVar( $obj )
+ );
+ }
+
/**
* @covers Xml::listDropDown
*/
$testModule = new ResourceLoaderFileModule( [
'localBasePath' => $basePath,
'styles' => [ 'bom.css' ],
- ] );
+ ] );
$testModule->setName( 'testing' );
$this->assertEquals(
substr( file_get_contents( "$basePath/bom.css" ), 0, 10 ),
'Using less variables is significant'
);
}
+
+ public function providerGetScriptPackageFiles() {
+ $basePath = __DIR__ . '/../../data/resourceloader';
+ $base = [ 'localBasePath' => $basePath ];
+ $commentScript = file_get_contents( "$basePath/script-comment.js" );
+ $nosemiScript = file_get_contents( "$basePath/script-nosemi.js" );
+ $config = RequestContext::getMain()->getConfig();
+ return [
+ [
+ $base + [
+ 'packageFiles' => [
+ 'script-comment.js',
+ 'script-nosemi.js'
+ ]
+ ],
+ [
+ 'files' => [
+ 'script-comment.js' => [
+ 'type' => 'script',
+ 'content' => $commentScript,
+ ],
+ 'script-nosemi.js' => [
+ 'type' => 'script',
+ 'content' => $nosemiScript
+ ]
+ ],
+ 'main' => 'script-comment.js'
+ ]
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ 'script-comment.js',
+ 'script-nosemi.js' => [ 'main' => true ]
+ ],
+ 'deprecated' => 'Deprecation test',
+ 'name' => 'test-deprecated'
+ ],
+ [
+ 'files' => [
+ 'script-comment.js' => [
+ 'type' => 'script',
+ 'content' => $commentScript,
+ ],
+ 'script-nosemi.js' => [
+ 'type' => 'script',
+ 'content' => 'mw.log.warn(' .
+ '"This page is using the deprecated ResourceLoader module \"test-deprecated\".\\n' .
+ "Deprecation test" .
+ '");' .
+ $nosemiScript
+ ]
+ ],
+ 'main' => 'script-nosemi.js'
+ ]
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ 'init.js' => [ 'file' => 'script-comment.js', 'main' => true ],
+ 'nosemi.js' => 'script-nosemi.js'
+ ]
+ ],
+ [
+ 'files' => [
+ 'init.js' => [
+ 'type' => 'script',
+ 'content' => $commentScript,
+ ],
+ 'nosemi.js' => [
+ 'type' => 'script',
+ 'content' => $nosemiScript
+ ]
+ ],
+ 'main' => 'init.js'
+ ]
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ 'foo.json' => [ 'content' => [ 'Hello' => 'world' ] ],
+ 'sample.json',
+ 'bar.js' => [ 'content' => "console.log('Hello');" ],
+ 'data' => [ 'type' => 'data', 'callback' => function ( $context ) {
+ return [ 'langCode' => $context->getLanguage() ];
+ } ],
+ 'config' => [ 'type' => 'data', 'config' => [
+ 'Sitename',
+ 'wgVersion' => 'Version',
+ ] ],
+ ]
+ ],
+ [
+ 'files' => [
+ 'foo.json' => [
+ 'type' => 'data',
+ 'content' => [ 'Hello' => 'world' ],
+ ],
+ 'sample.json' => [
+ 'type' => 'data',
+ 'content' => (object)[ 'foo' => 'bar', 'answer' => 42 ],
+ ],
+ 'bar.js' => [
+ 'type' => 'script',
+ 'content' => "console.log('Hello');",
+ ],
+ 'data' => [
+ 'type' => 'data',
+ 'content' => [ 'langCode' => 'fy' ]
+ ],
+ 'config' => [
+ 'type' => 'data',
+ 'content' => [
+ 'Sitename' => $config->get( 'Sitename' ),
+ 'wgVersion' => $config->get( 'Version' ),
+ ]
+ ]
+ ],
+ 'main' => 'bar.js'
+ ],
+ [
+ 'lang' => 'fy'
+ ]
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ [ 'file' => 'script-comment.js' ]
+ ]
+ ],
+ false
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ 'foo.json' => [ 'callback' => 'functionThatDoesNotExist142857' ]
+ ]
+ ],
+ false
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ 'foo' => [ 'type' => 'script', 'config' => [ 'Sitename' ] ]
+ ]
+ ],
+ false
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ 'foo.js' => [ 'config' => 'Sitename' ]
+ ]
+ ],
+ false
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ 'foo.js' => [ 'garbage' => 'data' ]
+ ]
+ ],
+ false
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ 'filethatdoesnotexist142857.js'
+ ]
+ ],
+ false
+ ],
+ [
+ $base + [
+ 'packageFiles' => [
+ 'script-nosemi.js',
+ 'foo.json' => [
+ 'type' => 'data',
+ 'content' => [ 'Hello' => 'world' ],
+ 'main' => true
+ ]
+ ]
+ ],
+ false
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider providerGetScriptPackageFiles
+ * @covers ResourceLoaderFileModule::getScript
+ * @covers ResourceLoaderFileModule::getPackageFiles
+ * @covers ResourceLoaderFileModule::expandPackageFiles
+ */
+ public function testGetScriptPackageFiles( $moduleDefinition, $expected, $contextOptions = [] ) {
+ $module = new ResourceLoaderFileModule( $moduleDefinition );
+ $context = $this->getResourceLoaderContext( $contextOptions );
+ if ( isset( $moduleDefinition['name'] ) ) {
+ $module->setName( $moduleDefinition['name'] );
+ }
+ if ( $expected === false ) {
+ $this->setExpectedException( MWException::class );
+ $module->getScript( $context );
+ } else {
+ $this->assertEquals( $expected, $module->getScript( $context ) );
+ }
+ }
}
'expected' => 'mw.loader.implement( "user", "mw.example( 1 );" );',
] ],
+ [ [
+ 'title' => 'Implement multi-file script',
+
+ 'name' => 'test.multifile',
+ 'scripts' => [
+ 'files' => [
+ 'one.js' => [
+ 'type' => 'script',
+ 'content' => 'mw.example( 1 );',
+ ],
+ 'two.json' => [
+ 'type' => 'data',
+ 'content' => [ 'n' => 2 ],
+ ],
+ 'three.js' => [
+ 'type' => 'script',
+ 'content' => 'mw.example( 3 );'
+ ],
+ ],
+ 'main' => 'three.js',
+ ],
+
+ 'expected' => <<<END
+mw.loader.implement( "test.multifile", {
+ "main": "three.js",
+ "files": {
+ "one.js": function ( require, module ) {
+mw.example( 1 );
+},
+ "two.json": {
+ "n": 2
+},
+ "three.js": function ( require, module ) {
+mw.example( 3 );
+}
+}
+} );
+END
+ ] ],
];
}
public function testMakeLoaderImplementScript( $case ) {
$case += [
'wrap' => true,
- 'styles' => [], 'templates' => [], 'messages' => new XmlJsCode( '{}' )
+ 'styles' => [], 'templates' => [], 'messages' => new XmlJsCode( '{}' ), 'packageFiles' => [],
];
ResourceLoader::clearCache();
$this->setMwGlobals( 'wgResourceLoaderDebug', true );
: $case['scripts'],
$case['styles'],
$case['messages'],
- $case['templates']
+ $case['templates'],
+ $case['packageFiles']
)
);
}
123, // scripts
null, // styles
null, // messages
- null // templates
+ null, // templates
+ null // package files
);
}
} );
} );
+ QUnit.test( '.implement( package files )', function ( assert ) {
+ var done = assert.async(),
+ initJsRan = false;
+ mw.loader.implement(
+ 'test.implement.packageFiles',
+ {
+ main: 'resources/src/foo/init.js',
+ files: {
+ 'resources/src/foo/data/hello.json': { hello: 'world' },
+ 'resources/src/foo/foo.js': function ( require, module ) {
+ window.mwTestFooJsCounter = window.mwTestFooJsCounter || 41;
+ window.mwTestFooJsCounter++;
+ module.exports = { answer: window.mwTestFooJsCounter };
+ },
+ 'resources/src/bar/bar.js': function ( require, module ) {
+ var core = require( './core.js' );
+ module.exports = { data: core.sayHello( 'Alice' ) };
+ },
+ 'resources/src/bar/core.js': function ( require, module ) {
+ module.exports = { sayHello: function ( name ) {
+ return 'Hello ' + name;
+ } };
+ },
+ 'resources/src/foo/init.js': function ( require ) {
+ initJsRan = true;
+ assert.deepEqual( require( './data/hello.json' ), { hello: 'world' }, 'require() with .json file' );
+ assert.deepEqual( require( './foo.js' ), { answer: 42 }, 'require() with .js file in same directory' );
+ assert.deepEqual( require( '../bar/bar.js' ), { data: 'Hello Alice' }, 'require() with ../ of a file that uses same-directory require()' );
+ assert.deepEqual( require( './foo.js' ), { answer: 42 }, 'require()ing the same script twice only runs it once' );
+ }
+ }
+ },
+ {},
+ {},
+ {}
+ );
+ mw.loader.using( 'test.implement.packageFiles' ).done( function () {
+ assert.ok( initJsRan, 'main JS file is executed' );
+ done();
+ } );
+ } );
+
QUnit.test( '.addSource()', function ( assert ) {
mw.loader.addSource( { testsource1: 'https://1.test/src' } );