summaryrefslogtreecommitdiff
path: root/includes/resourceloader
diff options
context:
space:
mode:
Diffstat (limited to 'includes/resourceloader')
-rw-r--r--includes/resourceloader/ResourceLoader.php740
-rw-r--r--includes/resourceloader/ResourceLoaderContext.php176
-rw-r--r--includes/resourceloader/ResourceLoaderFileModule.php509
-rw-r--r--includes/resourceloader/ResourceLoaderModule.php239
-rw-r--r--includes/resourceloader/ResourceLoaderSiteModule.php63
-rw-r--r--includes/resourceloader/ResourceLoaderStartUpModule.php225
-rw-r--r--includes/resourceloader/ResourceLoaderUserModule.php50
-rw-r--r--includes/resourceloader/ResourceLoaderUserOptionsModule.php121
-rw-r--r--includes/resourceloader/ResourceLoaderWikiModule.php171
9 files changed, 2294 insertions, 0 deletions
diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php
new file mode 100644
index 00000000..c18022a4
--- /dev/null
+++ b/includes/resourceloader/ResourceLoader.php
@@ -0,0 +1,740 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Roan Kattouw
+ * @author Trevor Parscal
+ */
+
+/**
+ * Dynamic JavaScript and CSS resource loading system.
+ *
+ * Most of the documention is on the MediaWiki documentation wiki starting at:
+ * http://www.mediawiki.org/wiki/ResourceLoader
+ */
+class ResourceLoader {
+
+ /* Protected Static Members */
+ protected static $filterCacheVersion = 2;
+
+ /** Array: List of module name/ResourceLoaderModule object pairs */
+ protected $modules = array();
+ /** Associative array mapping module name to info associative array */
+ protected $moduleInfos = array();
+
+ /* Protected Methods */
+
+ /**
+ * Loads information stored in the database about modules.
+ *
+ * This method grabs modules dependencies from the database and updates modules
+ * objects.
+ *
+ * This is not inside the module code because it is much faster to
+ * request all of the information at once than it is to have each module
+ * requests its own information. This sacrifice of modularity yields a substantial
+ * performance improvement.
+ *
+ * @param $modules Array: List of module names to preload information for
+ * @param $context ResourceLoaderContext: Context to load the information within
+ */
+ public function preloadModuleInfo( array $modules, ResourceLoaderContext $context ) {
+ if ( !count( $modules ) ) {
+ return; // or else Database*::select() will explode, plus it's cheaper!
+ }
+ $dbr = wfGetDB( DB_SLAVE );
+ $skin = $context->getSkin();
+ $lang = $context->getLanguage();
+
+ // Get file dependency information
+ $res = $dbr->select( 'module_deps', array( 'md_module', 'md_deps' ), array(
+ 'md_module' => $modules,
+ 'md_skin' => $context->getSkin()
+ ), __METHOD__
+ );
+
+ // Set modules' dependencies
+ $modulesWithDeps = array();
+ foreach ( $res as $row ) {
+ $this->getModule( $row->md_module )->setFileDependencies( $skin,
+ FormatJson::decode( $row->md_deps, true )
+ );
+ $modulesWithDeps[] = $row->md_module;
+ }
+
+ // Register the absence of a dependency row too
+ foreach ( array_diff( $modules, $modulesWithDeps ) as $name ) {
+ $this->getModule( $name )->setFileDependencies( $skin, array() );
+ }
+
+ // Get message blob mtimes. Only do this for modules with messages
+ $modulesWithMessages = array();
+ foreach ( $modules as $name ) {
+ if ( count( $this->getModule( $name )->getMessages() ) ) {
+ $modulesWithMessages[] = $name;
+ }
+ }
+ $modulesWithoutMessages = array_flip( $modules ); // Will be trimmed down by the loop below
+ if ( count( $modulesWithMessages ) ) {
+ $res = $dbr->select( 'msg_resource', array( 'mr_resource', 'mr_timestamp' ), array(
+ 'mr_resource' => $modulesWithMessages,
+ 'mr_lang' => $lang
+ ), __METHOD__
+ );
+ foreach ( $res as $row ) {
+ $this->getModule( $row->mr_resource )->setMsgBlobMtime( $lang, $row->mr_timestamp );
+ unset( $modulesWithoutMessages[$row->mr_resource] );
+ }
+ }
+ foreach ( array_keys( $modulesWithoutMessages ) as $name ) {
+ $this->getModule( $name )->setMsgBlobMtime( $lang, 0 );
+ }
+ }
+
+ /**
+ * Runs JavaScript or CSS data through a filter, caching the filtered result for future calls.
+ *
+ * Available filters are:
+ * - minify-js \see JavaScriptMinifier::minify
+ * - minify-css \see CSSMin::minify
+ *
+ * If $data is empty, only contains whitespace or the filter was unknown,
+ * $data is returned unmodified.
+ *
+ * @param $filter String: Name of filter to run
+ * @param $data String: Text to filter, such as JavaScript or CSS text
+ * @return String: Filtered data, or a comment containing an error message
+ */
+ protected function filter( $filter, $data ) {
+ global $wgResourceLoaderMinifierStatementsOnOwnLine, $wgResourceLoaderMinifierMaxLineLength;
+ wfProfileIn( __METHOD__ );
+
+ // For empty/whitespace-only data or for unknown filters, don't perform
+ // any caching or processing
+ if ( trim( $data ) === ''
+ || !in_array( $filter, array( 'minify-js', 'minify-css' ) ) )
+ {
+ wfProfileOut( __METHOD__ );
+ return $data;
+ }
+
+ // Try for cache hit
+ // Use CACHE_ANYTHING since filtering is very slow compared to DB queries
+ $key = wfMemcKey( 'resourceloader', 'filter', $filter, md5( $data ) );
+ $cache = wfGetCache( CACHE_ANYTHING );
+ $cacheEntry = $cache->get( $key );
+ if ( is_string( $cacheEntry ) ) {
+ wfProfileOut( __METHOD__ );
+ return $cacheEntry;
+ }
+
+ // Run the filter - we've already verified one of these will work
+ try {
+ switch ( $filter ) {
+ case 'minify-js':
+ $result = JavaScriptMinifier::minify( $data,
+ $wgResourceLoaderMinifierStatementsOnOwnLine,
+ $wgResourceLoaderMinifierMaxLineLength
+ );
+ break;
+ case 'minify-css':
+ $result = CSSMin::minify( $data );
+ break;
+ }
+
+ // Save filtered text to Memcached
+ $cache->set( $key, $result );
+ } catch ( Exception $exception ) {
+ // Return exception as a comment
+ $result = "/*\n{$exception->__toString()}\n*/\n";
+ }
+
+ wfProfileOut( __METHOD__ );
+
+ return $result;
+ }
+
+ /* Methods */
+
+ /**
+ * Registers core modules and runs registration hooks.
+ */
+ public function __construct() {
+ global $IP, $wgResourceModules;
+
+ wfProfileIn( __METHOD__ );
+
+ // Register core modules
+ $this->register( include( "$IP/resources/Resources.php" ) );
+ // Register extension modules
+ wfRunHooks( 'ResourceLoaderRegisterModules', array( &$this ) );
+ $this->register( $wgResourceModules );
+
+ wfProfileOut( __METHOD__ );
+ }
+
+ /**
+ * Registers a module with the ResourceLoader system.
+ *
+ * @param $name Mixed: Name of module as a string or List of name/object pairs as an array
+ * @param $info Module info array. For backwards compatibility with 1.17alpha,
+ * this may also be a ResourceLoaderModule object. Optional when using
+ * multiple-registration calling style.
+ * @throws MWException: If a duplicate module registration is attempted
+ * @throws MWException: If a module name contains illegal characters (pipes or commas)
+ * @throws MWException: If something other than a ResourceLoaderModule is being registered
+ * @return Boolean: False if there were any errors, in which case one or more modules were not
+ * registered
+ */
+ public function register( $name, $info = null ) {
+ wfProfileIn( __METHOD__ );
+
+ // Allow multiple modules to be registered in one call
+ if ( is_array( $name ) ) {
+ foreach ( $name as $key => $value ) {
+ $this->register( $key, $value );
+ }
+ wfProfileOut( __METHOD__ );
+ return;
+ }
+
+ // Disallow duplicate registrations
+ if ( isset( $this->moduleInfos[$name] ) ) {
+ // A module has already been registered by this name
+ throw new MWException(
+ 'ResourceLoader duplicate registration error. ' .
+ 'Another module has already been registered as ' . $name
+ );
+ }
+
+ // Check $name for illegal characters
+ if ( preg_match( '/[|,]/', $name ) ) {
+ throw new MWException( "ResourceLoader module name '$name' is invalid. Names may not contain pipes (|) or commas (,)" );
+ }
+
+ // Attach module
+ if ( is_object( $info ) ) {
+ // Old calling convention
+ // Validate the input
+ if ( !( $info instanceof ResourceLoaderModule ) ) {
+ throw new MWException( 'ResourceLoader invalid module error. ' .
+ 'Instances of ResourceLoaderModule expected.' );
+ }
+
+ $this->moduleInfos[$name] = array( 'object' => $info );
+ $info->setName( $name );
+ $this->modules[$name] = $info;
+ } else {
+ // New calling convention
+ $this->moduleInfos[$name] = $info;
+ }
+
+ wfProfileOut( __METHOD__ );
+ }
+
+ /**
+ * Get a list of module names
+ *
+ * @return Array: List of module names
+ */
+ public function getModuleNames() {
+ return array_keys( $this->moduleInfos );
+ }
+
+ /**
+ * Get the ResourceLoaderModule object for a given module name.
+ *
+ * @param $name String: Module name
+ * @return Mixed: ResourceLoaderModule if module has been registered, null otherwise
+ */
+ public function getModule( $name ) {
+ if ( !isset( $this->modules[$name] ) ) {
+ if ( !isset( $this->moduleInfos[$name] ) ) {
+ // No such module
+ return null;
+ }
+ // Construct the requested object
+ $info = $this->moduleInfos[$name];
+ if ( isset( $info['object'] ) ) {
+ // Object given in info array
+ $object = $info['object'];
+ } else {
+ if ( !isset( $info['class'] ) ) {
+ $class = 'ResourceLoaderFileModule';
+ } else {
+ $class = $info['class'];
+ }
+ $object = new $class( $info );
+ }
+ $object->setName( $name );
+ $this->modules[$name] = $object;
+ }
+
+ return $this->modules[$name];
+ }
+
+ /**
+ * Outputs a response to a resource load-request, including a content-type header.
+ *
+ * @param $context ResourceLoaderContext: Context in which a response should be formed
+ */
+ public function respond( ResourceLoaderContext $context ) {
+ global $wgResourceLoaderMaxage, $wgCacheEpoch;
+
+ // Buffer output to catch warnings. Normally we'd use ob_clean() on the
+ // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
+ // is used: ob_clean() will clear the GZIP header in that case and it won't come
+ // back for subsequent output, resulting in invalid GZIP. So we have to wrap
+ // the whole thing in our own output buffer to be sure the active buffer
+ // doesn't use ob_gzhandler.
+ // See http://bugs.php.net/bug.php?id=36514
+ ob_start();
+
+ wfProfileIn( __METHOD__ );
+ $exceptions = '';
+
+ // Split requested modules into two groups, modules and missing
+ $modules = array();
+ $missing = array();
+ foreach ( $context->getModules() as $name ) {
+ if ( isset( $this->moduleInfos[$name] ) ) {
+ $modules[$name] = $this->getModule( $name );
+ } else {
+ $missing[] = $name;
+ }
+ }
+
+ // If a version wasn't specified we need a shorter expiry time for updates
+ // to propagate to clients quickly
+ if ( is_null( $context->getVersion() ) ) {
+ $maxage = $wgResourceLoaderMaxage['unversioned']['client'];
+ $smaxage = $wgResourceLoaderMaxage['unversioned']['server'];
+ }
+ // If a version was specified we can use a longer expiry time since changing
+ // version numbers causes cache misses
+ else {
+ $maxage = $wgResourceLoaderMaxage['versioned']['client'];
+ $smaxage = $wgResourceLoaderMaxage['versioned']['server'];
+ }
+
+ // Preload information needed to the mtime calculation below
+ try {
+ $this->preloadModuleInfo( array_keys( $modules ), $context );
+ } catch( Exception $e ) {
+ // Add exception to the output as a comment
+ $exceptions .= "/*\n{$e->__toString()}\n*/\n";
+ }
+
+ wfProfileIn( __METHOD__.'-getModifiedTime' );
+
+ $private = false;
+ // To send Last-Modified and support If-Modified-Since, we need to detect
+ // the last modified time
+ $mtime = wfTimestamp( TS_UNIX, $wgCacheEpoch );
+ foreach ( $modules as $module ) {
+ try {
+ // Bypass Squid and other shared caches if the request includes any private modules
+ if ( $module->getGroup() === 'private' ) {
+ $private = true;
+ }
+ // Calculate maximum modified time
+ $mtime = max( $mtime, $module->getModifiedTime( $context ) );
+ } catch ( Exception $e ) {
+ // Add exception to the output as a comment
+ $exceptions .= "/*\n{$e->__toString()}\n*/\n";
+ }
+ }
+
+ wfProfileOut( __METHOD__.'-getModifiedTime' );
+
+ if ( $context->getOnly() === 'styles' ) {
+ header( 'Content-Type: text/css; charset=utf-8' );
+ } else {
+ header( 'Content-Type: text/javascript; charset=utf-8' );
+ }
+ header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $mtime ) );
+ if ( $context->getDebug() ) {
+ // Do not cache debug responses
+ header( 'Cache-Control: private, no-cache, must-revalidate' );
+ header( 'Pragma: no-cache' );
+ } else {
+ if ( $private ) {
+ header( "Cache-Control: private, max-age=$maxage" );
+ $exp = $maxage;
+ } else {
+ header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
+ $exp = min( $maxage, $smaxage );
+ }
+ header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
+ }
+
+ // If there's an If-Modified-Since header, respond with a 304 appropriately
+ // Some clients send "timestamp;length=123". Strip the part after the first ';'
+ // so we get a valid timestamp.
+ $ims = $context->getRequest()->getHeader( 'If-Modified-Since' );
+ if ( $ims !== false ) {
+ $imsTS = strtok( $ims, ';' );
+ if ( $mtime <= wfTimestamp( TS_UNIX, $imsTS ) ) {
+ // There's another bug in ob_gzhandler (see also the comment at
+ // the top of this function) that causes it to gzip even empty
+ // responses, meaning it's impossible to produce a truly empty
+ // response (because the gzip header is always there). This is
+ // a problem because 304 responses have to be completely empty
+ // per the HTTP spec, and Firefox behaves buggily when they're not.
+ // See also http://bugs.php.net/bug.php?id=51579
+ // To work around this, we tear down all output buffering before
+ // sending the 304.
+ // On some setups, ob_get_level() doesn't seem to go down to zero
+ // no matter how often we call ob_get_clean(), so instead of doing
+ // the more intuitive while ( ob_get_level() > 0 ) ob_get_clean();
+ // we have to be safe here and avoid an infinite loop.
+ for ( $i = 0; $i < ob_get_level(); $i++ ) {
+ ob_end_clean();
+ }
+
+ header( 'HTTP/1.0 304 Not Modified' );
+ header( 'Status: 304 Not Modified' );
+ wfProfileOut( __METHOD__ );
+ return;
+ }
+ }
+
+ // Generate a response
+ $response = $this->makeModuleResponse( $context, $modules, $missing );
+
+ // Prepend comments indicating exceptions
+ $response = $exceptions . $response;
+
+ // Capture any PHP warnings from the output buffer and append them to the
+ // response in a comment if we're in debug mode.
+ if ( $context->getDebug() && strlen( $warnings = ob_get_contents() ) ) {
+ $response = "/*\n$warnings\n*/\n" . $response;
+ }
+
+ // Remove the output buffer and output the response
+ ob_end_clean();
+ echo $response;
+
+ wfProfileOut( __METHOD__ );
+ }
+
+ /**
+ * Generates code for a response
+ *
+ * @param $context ResourceLoaderContext: Context in which to generate a response
+ * @param $modules Array: List of module objects keyed by module name
+ * @param $missing Array: List of unavailable modules (optional)
+ * @return String: Response data
+ */
+ public function makeModuleResponse( ResourceLoaderContext $context,
+ array $modules, $missing = array() )
+ {
+ $out = '';
+ $exceptions = '';
+ if ( $modules === array() && $missing === array() ) {
+ return '/* No modules requested. Max made me put this here */';
+ }
+
+ wfProfileIn( __METHOD__ );
+ // Pre-fetch blobs
+ if ( $context->shouldIncludeMessages() ) {
+ try {
+ $blobs = MessageBlobStore::get( $this, $modules, $context->getLanguage() );
+ } catch ( Exception $e ) {
+ // Add exception to the output as a comment
+ $exceptions .= "/*\n{$e->__toString()}\n*/\n";
+ }
+ } else {
+ $blobs = array();
+ }
+
+ // Generate output
+ foreach ( $modules as $name => $module ) {
+ wfProfileIn( __METHOD__ . '-' . $name );
+ try {
+ // Scripts
+ $scripts = '';
+ if ( $context->shouldIncludeScripts() ) {
+ // bug 27054: Append semicolon to prevent weird bugs
+ // caused by files not terminating their statements right
+ $scripts .= $module->getScript( $context ) . ";\n";
+ }
+
+ // Styles
+ $styles = array();
+ if ( $context->shouldIncludeStyles() ) {
+ $styles = $module->getStyles( $context );
+ }
+
+ // Messages
+ $messagesBlob = isset( $blobs[$name] ) ? $blobs[$name] : '{}';
+
+ // Append output
+ switch ( $context->getOnly() ) {
+ case 'scripts':
+ $out .= $scripts;
+ break;
+ case 'styles':
+ $out .= self::makeCombinedStyles( $styles );
+ break;
+ case 'messages':
+ $out .= self::makeMessageSetScript( new XmlJsCode( $messagesBlob ) );
+ break;
+ default:
+ // Minify CSS before embedding in mediaWiki.loader.implement call
+ // (unless in debug mode)
+ if ( !$context->getDebug() ) {
+ foreach ( $styles as $media => $style ) {
+ $styles[$media] = $this->filter( 'minify-css', $style );
+ }
+ }
+ $out .= self::makeLoaderImplementScript( $name, $scripts, $styles,
+ new XmlJsCode( $messagesBlob ) );
+ break;
+ }
+ } catch ( Exception $e ) {
+ // Add exception to the output as a comment
+ $exceptions .= "/*\n{$e->__toString()}\n*/\n";
+
+ // Register module as missing
+ $missing[] = $name;
+ unset( $modules[$name] );
+ }
+ wfProfileOut( __METHOD__ . '-' . $name );
+ }
+
+ // Update module states
+ if ( $context->shouldIncludeScripts() ) {
+ // Set the state of modules loaded as only scripts to ready
+ if ( count( $modules ) && $context->getOnly() === 'scripts'
+ && !isset( $modules['startup'] ) )
+ {
+ $out .= self::makeLoaderStateScript(
+ array_fill_keys( array_keys( $modules ), 'ready' ) );
+ }
+ // Set the state of modules which were requested but unavailable as missing
+ if ( is_array( $missing ) && count( $missing ) ) {
+ $out .= self::makeLoaderStateScript( array_fill_keys( $missing, 'missing' ) );
+ }
+ }
+
+ if ( !$context->getDebug() ) {
+ if ( $context->getOnly() === 'styles' ) {
+ $out = $this->filter( 'minify-css', $out );
+ } else {
+ $out = $this->filter( 'minify-js', $out );
+ }
+ }
+
+ wfProfileOut( __METHOD__ );
+ return $exceptions . $out;
+ }
+
+ /* Static Methods */
+
+ /**
+ * Returns JS code to call to mediaWiki.loader.implement for a module with
+ * given properties.
+ *
+ * @param $name Module name
+ * @param $scripts Array: List of JavaScript code snippets to be executed after the
+ * module is loaded
+ * @param $styles Array: List of CSS strings keyed by media type
+ * @param $messages Mixed: List of messages associated with this module. May either be an
+ * associative array mapping message key to value, or a JSON-encoded message blob containing
+ * the same data, wrapped in an XmlJsCode object.
+ */
+ public static function makeLoaderImplementScript( $name, $scripts, $styles, $messages ) {
+ if ( is_array( $scripts ) ) {
+ $scripts = implode( $scripts, "\n" );
+ }
+ return Xml::encodeJsCall(
+ 'mediaWiki.loader.implement',
+ array(
+ $name,
+ new XmlJsCode( "function( $, mw ) {{$scripts}}" ),
+ (object)$styles,
+ (object)$messages
+ ) );
+ }
+
+ /**
+ * Returns JS code which, when called, will register a given list of messages.
+ *
+ * @param $messages Mixed: Either an associative array mapping message key to value, or a
+ * JSON-encoded message blob containing the same data, wrapped in an XmlJsCode object.
+ */
+ public static function makeMessageSetScript( $messages ) {
+ return Xml::encodeJsCall( 'mediaWiki.messages.set', array( (object)$messages ) );
+ }
+
+ /**
+ * Combines an associative array mapping media type to CSS into a
+ * single stylesheet with @media blocks.
+ *
+ * @param $styles Array: List of CSS strings keyed by media type
+ */
+ public static function makeCombinedStyles( array $styles ) {
+ $out = '';
+ foreach ( $styles as $media => $style ) {
+ // Transform the media type based on request params and config
+ // The way that this relies on $wgRequest to propagate request params is slightly evil
+ $media = OutputPage::transformCssMedia( $media );
+
+ if ( $media === null ) {
+ // Skip
+ } else if ( $media === '' || $media == 'all' ) {
+ // Don't output invalid or frivolous @media statements
+ $out .= "$style\n";
+ } else {
+ $out .= "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "\n}\n";
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Returns a JS call to mediaWiki.loader.state, which sets the state of a
+ * module or modules to a given value. Has two calling conventions:
+ *
+ * - ResourceLoader::makeLoaderStateScript( $name, $state ):
+ * Set the state of a single module called $name to $state
+ *
+ * - ResourceLoader::makeLoaderStateScript( array( $name => $state, ... ) ):
+ * Set the state of modules with the given names to the given states
+ */
+ public static function makeLoaderStateScript( $name, $state = null ) {
+ if ( is_array( $name ) ) {
+ return Xml::encodeJsCall( 'mediaWiki.loader.state', array( $name ) );
+ } else {
+ return Xml::encodeJsCall( 'mediaWiki.loader.state', array( $name, $state ) );
+ }
+ }
+
+ /**
+ * Returns JS code which calls the script given by $script. The script will
+ * be called with local variables name, version, dependencies and group,
+ * which will have values corresponding to $name, $version, $dependencies
+ * and $group as supplied.
+ *
+ * @param $name String: Module name
+ * @param $version Integer: Module version number as a timestamp
+ * @param $dependencies Array: List of module names on which this module depends
+ * @param $group String: Group which the module is in.
+ * @param $script String: JavaScript code
+ */
+ public static function makeCustomLoaderScript( $name, $version, $dependencies, $group, $script ) {
+ $script = str_replace( "\n", "\n\t", trim( $script ) );
+ return Xml::encodeJsCall(
+ "( function( name, version, dependencies, group ) {\n\t$script\n} )",
+ array( $name, $version, $dependencies, $group ) );
+ }
+
+ /**
+ * Returns JS code which calls mediaWiki.loader.register with the given
+ * parameters. Has three calling conventions:
+ *
+ * - ResourceLoader::makeLoaderRegisterScript( $name, $version, $dependencies, $group ):
+ * Register a single module.
+ *
+ * - ResourceLoader::makeLoaderRegisterScript( array( $name1, $name2 ) ):
+ * Register modules with the given names.
+ *
+ * - ResourceLoader::makeLoaderRegisterScript( array(
+ * array( $name1, $version1, $dependencies1, $group1 ),
+ * array( $name2, $version2, $dependencies1, $group2 ),
+ * ...
+ * ) ):
+ * Registers modules with the given names and parameters.
+ *
+ * @param $name String: Module name
+ * @param $version Integer: Module version number as a timestamp
+ * @param $dependencies Array: List of module names on which this module depends
+ * @param $group String: group which the module is in.
+ */
+ public static function makeLoaderRegisterScript( $name, $version = null,
+ $dependencies = null, $group = null )
+ {
+ if ( is_array( $name ) ) {
+ return Xml::encodeJsCall( 'mediaWiki.loader.register', array( $name ) );
+ } else {
+ $version = (int) $version > 1 ? (int) $version : 1;
+ return Xml::encodeJsCall( 'mediaWiki.loader.register',
+ array( $name, $version, $dependencies, $group ) );
+ }
+ }
+
+ /**
+ * Returns JS code which runs given JS code if the client-side framework is
+ * present.
+ *
+ * @param $script String: JavaScript code
+ */
+ public static function makeLoaderConditionalScript( $script ) {
+ $script = str_replace( "\n", "\n\t", trim( $script ) );
+ return "if ( window.mediaWiki ) {\n\t$script\n}\n";
+ }
+
+ /**
+ * Returns JS code which will set the MediaWiki configuration array to
+ * the given value.
+ *
+ * @param $configuration Array: List of configuration values keyed by variable name
+ */
+ public static function makeConfigSetScript( array $configuration ) {
+ return Xml::encodeJsCall( 'mediaWiki.config.set', array( $configuration ) );
+ }
+
+ /**
+ * Convert an array of module names to a packed query string.
+ *
+ * For example, array( 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' )
+ * becomes 'foo.bar,baz|bar.baz,quux'
+ * @param $modules array of module names (strings)
+ * @return string Packed query string
+ */
+ public static function makePackedModulesString( $modules ) {
+ $groups = array(); // array( prefix => array( suffixes ) )
+ foreach ( $modules as $module ) {
+ $pos = strrpos( $module, '.' );
+ $prefix = $pos === false ? '' : substr( $module, 0, $pos );
+ $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
+ $groups[$prefix][] = $suffix;
+ }
+
+ $arr = array();
+ foreach ( $groups as $prefix => $suffixes ) {
+ $p = $prefix === '' ? '' : $prefix . '.';
+ $arr[] = $p . implode( ',', $suffixes );
+ }
+ return implode( '|', $arr );
+ }
+
+ /**
+ * Determine whether debug mode was requested
+ * Order of priority is 1) request param, 2) cookie, 3) $wg setting
+ * @return bool
+ */
+ public static function inDebugMode() {
+ global $wgRequest, $wgResourceLoaderDebug;
+ static $retval = null;
+ if ( !is_null( $retval ) )
+ return $retval;
+ return $retval = $wgRequest->getFuzzyBool( 'debug',
+ $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug ) );
+ }
+}
diff --git a/includes/resourceloader/ResourceLoaderContext.php b/includes/resourceloader/ResourceLoaderContext.php
new file mode 100644
index 00000000..bf059b46
--- /dev/null
+++ b/includes/resourceloader/ResourceLoaderContext.php
@@ -0,0 +1,176 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Object passed around to modules which contains information about the state
+ * of a specific loader request
+ */
+class ResourceLoaderContext {
+
+ /* Protected Members */
+
+ protected $resourceLoader;
+ protected $request;
+ protected $modules;
+ protected $language;
+ protected $direction;
+ protected $skin;
+ protected $user;
+ protected $debug;
+ protected $only;
+ protected $version;
+ protected $hash;
+
+ /* Methods */
+
+ public function __construct( ResourceLoader $resourceLoader, WebRequest $request ) {
+ global $wgDefaultSkin, $wgResourceLoaderDebug;
+
+ $this->resourceLoader = $resourceLoader;
+ $this->request = $request;
+
+ // Interpret request
+ // List of modules
+ $modules = $request->getVal( 'modules' );
+ $this->modules = $modules ? self::expandModuleNames( $modules ) : array();
+ // Various parameters
+ $this->skin = $request->getVal( 'skin' );
+ $this->user = $request->getVal( 'user' );
+ $this->debug = $request->getFuzzyBool( 'debug', $wgResourceLoaderDebug );
+ $this->only = $request->getVal( 'only' );
+ $this->version = $request->getVal( 'version' );
+
+ if ( !$this->skin ) {
+ $this->skin = $wgDefaultSkin;
+ }
+ }
+
+ /**
+ * Expand a string of the form jquery.foo,bar|jquery.ui.baz,quux to
+ * an array of module names like array( 'jquery.foo', 'jquery.bar',
+ * 'jquery.ui.baz', 'jquery.ui.quux' )
+ * @param $modules String Packed module name list
+ * @return array of module names
+ */
+ public static function expandModuleNames( $modules ) {
+ $retval = array();
+ $exploded = explode( '|', $modules );
+ foreach ( $exploded as $group ) {
+ if ( strpos( $group, ',' ) === false ) {
+ // This is not a set of modules in foo.bar,baz notation
+ // but a single module
+ $retval[] = $group;
+ } else {
+ // This is a set of modules in foo.bar,baz notation
+ $pos = strrpos( $group, '.' );
+ if ( $pos === false ) {
+ // Prefixless modules, i.e. without dots
+ $retval = explode( ',', $group );
+ } else {
+ // We have a prefix and a bunch of suffixes
+ $prefix = substr( $group, 0, $pos ); // 'foo'
+ $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // array( 'bar', 'baz' )
+ foreach ( $suffixes as $suffix ) {
+ $retval[] = "$prefix.$suffix";
+ }
+ }
+ }
+ }
+ return $retval;
+ }
+
+ public function getResourceLoader() {
+ return $this->resourceLoader;
+ }
+
+ public function getRequest() {
+ return $this->request;
+ }
+
+ public function getModules() {
+ return $this->modules;
+ }
+
+ public function getLanguage() {
+ if ( $this->language === null ) {
+ global $wgLang;
+ $this->language = $this->request->getVal( 'lang' );
+ if ( !$this->language ) {
+ $this->language = $wgLang->getCode();
+ }
+ }
+ return $this->language;
+ }
+
+ public function getDirection() {
+ if ( $this->direction === null ) {
+ $this->direction = $this->request->getVal( 'dir' );
+ if ( !$this->direction ) {
+ global $wgContLang;
+ $this->direction = $wgContLang->getDir();
+ }
+ }
+ return $this->direction;
+ }
+
+ public function getSkin() {
+ return $this->skin;
+ }
+
+ public function getUser() {
+ return $this->user;
+ }
+
+ public function getDebug() {
+ return $this->debug;
+ }
+
+ public function getOnly() {
+ return $this->only;
+ }
+
+ public function getVersion() {
+ return $this->version;
+ }
+
+ public function shouldIncludeScripts() {
+ return is_null( $this->only ) || $this->only === 'scripts';
+ }
+
+ public function shouldIncludeStyles() {
+ return is_null( $this->only ) || $this->only === 'styles';
+ }
+
+ public function shouldIncludeMessages() {
+ return is_null( $this->only ) || $this->only === 'messages';
+ }
+
+ public function getHash() {
+ if ( !isset( $this->hash ) ) {
+ $this->hash = implode( '|', array(
+ $this->getLanguage(), $this->getDirection(), $this->skin, $this->user,
+ $this->debug, $this->only, $this->version
+ ) );
+ }
+ return $this->hash;
+ }
+}
diff --git a/includes/resourceloader/ResourceLoaderFileModule.php b/includes/resourceloader/ResourceLoaderFileModule.php
new file mode 100644
index 00000000..44967a2e
--- /dev/null
+++ b/includes/resourceloader/ResourceLoaderFileModule.php
@@ -0,0 +1,509 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * ResourceLoader module based on local JavaScript/CSS files.
+ */
+class ResourceLoaderFileModule extends ResourceLoaderModule {
+
+ /* Protected Members */
+
+ /** String: Local base path, see __construct() */
+ protected $localBasePath = '';
+ /** String: Remote base path, see __construct() */
+ protected $remoteBasePath = '';
+ /**
+ * Array: List of paths to JavaScript files to always include
+ * @example array( [file-path], [file-path], ... )
+ */
+ protected $scripts = array();
+ /**
+ * Array: List of JavaScript files to include when using a specific language
+ * @example array( [language-code] => array( [file-path], [file-path], ... ), ... )
+ */
+ protected $languageScripts = array();
+ /**
+ * Array: List of JavaScript files to include when using a specific skin
+ * @example array( [skin-name] => array( [file-path], [file-path], ... ), ... )
+ */
+ protected $skinScripts = array();
+ /**
+ * Array: List of paths to JavaScript files to include in debug mode
+ * @example array( [skin-name] => array( [file-path], [file-path], ... ), ... )
+ */
+ protected $debugScripts = array();
+ /**
+ * Array: List of paths to JavaScript files to include in the startup module
+ * @example array( [file-path], [file-path], ... )
+ */
+ protected $loaderScripts = array();
+ /**
+ * Array: List of paths to CSS files to always include
+ * @example array( [file-path], [file-path], ... )
+ */
+ protected $styles = array();
+ /**
+ * Array: List of paths to CSS files to include when using specific skins
+ * @example array( [file-path], [file-path], ... )
+ */
+ protected $skinStyles = array();
+ /**
+ * Array: List of modules this module depends on
+ * @example array( [file-path], [file-path], ... )
+ */
+ protected $dependencies = array();
+ /**
+ * Array: List of message keys used by this module
+ * @example array( [message-key], [message-key], ... )
+ */
+ protected $messages = array();
+ /** String: Name of group to load this module in */
+ protected $group;
+ /** Boolean: Link to raw files in debug mode */
+ protected $debugRaw = true;
+ /**
+ * Array: Cache for mtime
+ * @example array( [hash] => [mtime], [hash] => [mtime], ... )
+ */
+ protected $modifiedTime = array();
+ /**
+ * Array: Place where readStyleFile() tracks file dependencies
+ * @example array( [file-path], [file-path], ... )
+ */
+ protected $localFileRefs = array();
+
+ /* Methods */
+
+ /**
+ * Constructs a new module from an options array.
+ *
+ * @param $options Array: List of options; if not given or empty, an empty module will be
+ * constructed
+ * @param $localBasePath String: Base path to prepend to all local paths in $options. Defaults
+ * to $IP
+ * @param $remoteBasePath String: Base path to prepend to all remote paths in $options. Defaults
+ * to $wgScriptPath
+ *
+ * @example $options
+ * array(
+ * // Base path to prepend to all local paths in $options. Defaults to $IP
+ * 'localBasePath' => [base path],
+ * // Base path to prepend to all remote paths in $options. Defaults to $wgScriptPath
+ * 'remoteBasePath' => [base path],
+ * // Equivalent of remoteBasePath, but relative to $wgExtensionAssetsPath
+ * 'remoteExtPath' => [base path],
+ * // Scripts to always include
+ * 'scripts' => [file path string or array of file path strings],
+ * // Scripts to include in specific language contexts
+ * 'languageScripts' => array(
+ * [language code] => [file path string or array of file path strings],
+ * ),
+ * // Scripts to include in specific skin contexts
+ * 'skinScripts' => array(
+ * [skin name] => [file path string or array of file path strings],
+ * ),
+ * // Scripts to include in debug contexts
+ * 'debugScripts' => [file path string or array of file path strings],
+ * // Scripts to include in the startup module
+ * 'loaderScripts' => [file path string or array of file path strings],
+ * // Modules which must be loaded before this module
+ * 'dependencies' => [modile name string or array of module name strings],
+ * // Styles to always load
+ * 'styles' => [file path string or array of file path strings],
+ * // Styles to include in specific skin contexts
+ * 'skinStyles' => array(
+ * [skin name] => [file path string or array of file path strings],
+ * ),
+ * // Messages to always load
+ * 'messages' => [array of message key strings],
+ * // Group which this module should be loaded together with
+ * 'group' => [group name string],
+ * )
+ */
+ public function __construct( $options = array(), $localBasePath = null,
+ $remoteBasePath = null )
+ {
+ global $IP, $wgScriptPath;
+ $this->localBasePath = $localBasePath === null ? $IP : $localBasePath;
+ $this->remoteBasePath = $remoteBasePath === null ? $wgScriptPath : $remoteBasePath;
+
+ if ( isset( $options['remoteExtPath'] ) ) {
+ global $wgExtensionAssetsPath;
+ $this->remoteBasePath = $wgExtensionAssetsPath . '/' . $options['remoteExtPath'];
+ }
+
+ foreach ( $options as $member => $option ) {
+ switch ( $member ) {
+ // Lists of file paths
+ case 'scripts':
+ case 'debugScripts':
+ case 'loaderScripts':
+ case 'styles':
+ $this->{$member} = (array) $option;
+ break;
+ // Collated lists of file paths
+ case 'languageScripts':
+ case 'skinScripts':
+ case 'skinStyles':
+ if ( !is_array( $option ) ) {
+ throw new MWException(
+ "Invalid collated file path list error. " .
+ "'$option' given, array expected."
+ );
+ }
+ foreach ( $option as $key => $value ) {
+ if ( !is_string( $key ) ) {
+ throw new MWException(
+ "Invalid collated file path list key error. " .
+ "'$key' given, string expected."
+ );
+ }
+ $this->{$member}[$key] = (array) $value;
+ }
+ break;
+ // Lists of strings
+ case 'dependencies':
+ case 'messages':
+ $this->{$member} = (array) $option;
+ break;
+ // Single strings
+ case 'group':
+ case 'localBasePath':
+ case 'remoteBasePath':
+ $this->{$member} = (string) $option;
+ break;
+ // Single booleans
+ case 'debugRaw':
+ $this->{$member} = (bool) $option;
+ break;
+ }
+ }
+ // Make sure the remote base path is a complete valid url
+ $this->remoteBasePath = wfExpandUrl( $this->remoteBasePath );
+ }
+
+ /**
+ * Gets all scripts for a given context concatenated together.
+ *
+ * @param $context ResourceLoaderContext: Context in which to generate script
+ * @return String: JavaScript code for $context
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ $files = array_merge(
+ $this->scripts,
+ self::tryForKey( $this->languageScripts, $context->getLanguage() ),
+ self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
+ );
+ if ( $context->getDebug() ) {
+ $files = array_merge( $files, $this->debugScripts );
+ if ( $this->debugRaw ) {
+ $script = '';
+ foreach ( $files as $file ) {
+ $path = $this->getRemotePath( $file );
+ $script .= "\n\t" . Xml::encodeJsCall( 'mediaWiki.loader.load', array( $path ) );
+ }
+ return $script;
+ }
+ }
+ return $this->readScriptFiles( $files );
+ }
+
+ /**
+ * Gets loader script.
+ *
+ * @return String: JavaScript code to be added to startup module
+ */
+ public function getLoaderScript() {
+ if ( count( $this->loaderScripts ) == 0 ) {
+ return false;
+ }
+ return $this->readScriptFiles( $this->loaderScripts );
+ }
+
+ /**
+ * Gets all styles for a given context concatenated together.
+ *
+ * @param $context ResourceLoaderContext: Context in which to generate styles
+ * @return String: CSS code for $context
+ */
+ public function getStyles( ResourceLoaderContext $context ) {
+ // Merge general styles and skin specific styles, retaining media type collation
+ $styles = $this->readStyleFiles( $this->styles, $this->getFlip( $context ) );
+ $skinStyles = $this->readStyleFiles(
+ self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' ),
+ $this->getFlip( $context )
+ );
+
+ foreach ( $skinStyles as $media => $style ) {
+ if ( isset( $styles[$media] ) ) {
+ $styles[$media] .= $style;
+ } else {
+ $styles[$media] = $style;
+ }
+ }
+ // Collect referenced files
+ $this->localFileRefs = array_unique( $this->localFileRefs );
+ // If the list has been modified since last time we cached it, update the cache
+ if ( $this->localFileRefs !== $this->getFileDependencies( $context->getSkin() ) ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->replace( 'module_deps',
+ array( array( 'md_module', 'md_skin' ) ), array(
+ 'md_module' => $this->getName(),
+ 'md_skin' => $context->getSkin(),
+ 'md_deps' => FormatJson::encode( $this->localFileRefs ),
+ )
+ );
+ }
+ return $styles;
+ }
+
+ /**
+ * Gets list of message keys used by this module.
+ *
+ * @return Array: List of message keys
+ */
+ public function getMessages() {
+ return $this->messages;
+ }
+
+ /**
+ * Gets the name of the group this module should be loaded in.
+ *
+ * @return String: Group name
+ */
+ public function getGroup() {
+ return $this->group;
+ }
+
+ /**
+ * Gets list of names of modules this module depends on.
+ *
+ * @return Array: List of module names
+ */
+ public function getDependencies() {
+ return $this->dependencies;
+ }
+
+ /**
+ * Get the last modified timestamp of this module.
+ *
+ * Last modified timestamps are calculated from the highest last modified
+ * timestamp of this module's constituent files as well as the files it
+ * depends on. This function is context-sensitive, only performing
+ * calculations on files relevant to the given language, skin and debug
+ * mode.
+ *
+ * @param $context ResourceLoaderContext: Context in which to calculate
+ * the modified time
+ * @return Integer: UNIX timestamp
+ * @see ResourceLoaderModule::getFileDependencies
+ */
+ public function getModifiedTime( ResourceLoaderContext $context ) {
+ if ( isset( $this->modifiedTime[$context->getHash()] ) ) {
+ return $this->modifiedTime[$context->getHash()];
+ }
+ wfProfileIn( __METHOD__ );
+
+ $files = array();
+
+ // Flatten style files into $files
+ $styles = self::collateFilePathListByOption( $this->styles, 'media', 'all' );
+ foreach ( $styles as $styleFiles ) {
+ $files = array_merge( $files, $styleFiles );
+ }
+ $skinFiles = self::tryForKey(
+ self::collateFilePathListByOption( $this->skinStyles, 'media', 'all' ),
+ $context->getSkin(),
+ 'default'
+ );
+ foreach ( $skinFiles as $styleFiles ) {
+ $files = array_merge( $files, $styleFiles );
+ }
+
+ // Final merge, this should result in a master list of dependent files
+ $files = array_merge(
+ $files,
+ $this->scripts,
+ $context->getDebug() ? $this->debugScripts : array(),
+ self::tryForKey( $this->languageScripts, $context->getLanguage() ),
+ self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
+ $this->loaderScripts
+ );
+ $files = array_map( array( $this, 'getLocalPath' ), $files );
+ // File deps need to be treated separately because they're already prefixed
+ $files = array_merge( $files, $this->getFileDependencies( $context->getSkin() ) );
+
+ // If a module is nothing but a list of dependencies, we need to avoid
+ // giving max() an empty array
+ if ( count( $files ) === 0 ) {
+ wfProfileOut( __METHOD__ );
+ return $this->modifiedTime[$context->getHash()] = 1;
+ }
+
+ wfProfileIn( __METHOD__.'-filemtime' );
+ $filesMtime = max( array_map( 'filemtime', $files ) );
+ wfProfileOut( __METHOD__.'-filemtime' );
+ $this->modifiedTime[$context->getHash()] = max(
+ $filesMtime,
+ $this->getMsgBlobMtime( $context->getLanguage() ) );
+
+ wfProfileOut( __METHOD__ );
+ return $this->modifiedTime[$context->getHash()];
+ }
+
+ /* Protected Members */
+
+ protected function getLocalPath( $path ) {
+ return "{$this->localBasePath}/$path";
+ }
+
+ protected function getRemotePath( $path ) {
+ return "{$this->remoteBasePath}/$path";
+ }
+
+ /**
+ * Collates file paths by option (where provided).
+ *
+ * @param $list Array: List of file paths in any combination of index/path
+ * or path/options pairs
+ * @param $option String: option name
+ * @param $default Mixed: default value if the option isn't set
+ * @return Array: List of file paths, collated by $option
+ */
+ protected static function collateFilePathListByOption( array $list, $option, $default ) {
+ $collatedFiles = array();
+ foreach ( (array) $list as $key => $value ) {
+ if ( is_int( $key ) ) {
+ // File name as the value
+ if ( !isset( $collatedFiles[$default] ) ) {
+ $collatedFiles[$default] = array();
+ }
+ $collatedFiles[$default][] = $value;
+ } else if ( is_array( $value ) ) {
+ // File name as the key, options array as the value
+ $optionValue = isset( $value[$option] ) ? $value[$option] : $default;
+ if ( !isset( $collatedFiles[$optionValue] ) ) {
+ $collatedFiles[$optionValue] = array();
+ }
+ $collatedFiles[$optionValue][] = $key;
+ }
+ }
+ return $collatedFiles;
+ }
+
+ /**
+ * Gets a list of element that match a key, optionally using a fallback key.
+ *
+ * @param $list Array: List of lists to select from
+ * @param $key String: Key to look for in $map
+ * @param $fallback String: Key to look for in $list if $key doesn't exist
+ * @return Array: List of elements from $map which matched $key or $fallback,
+ * or an empty list in case of no match
+ */
+ protected static function tryForKey( array $list, $key, $fallback = null ) {
+ if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
+ return $list[$key];
+ } else if ( is_string( $fallback )
+ && isset( $list[$fallback] )
+ && is_array( $list[$fallback] ) )
+ {
+ return $list[$fallback];
+ }
+ return array();
+ }
+
+ /**
+ * Gets the contents of a list of JavaScript files.
+ *
+ * @param $scripts Array: List of file paths to scripts to read, remap and concetenate
+ * @return String: Concatenated and remapped JavaScript data from $scripts
+ */
+ protected function readScriptFiles( array $scripts ) {
+ if ( empty( $scripts ) ) {
+ return '';
+ }
+ $js = '';
+ foreach ( array_unique( $scripts ) as $fileName ) {
+ $localPath = $this->getLocalPath( $fileName );
+ $contents = file_get_contents( $localPath );
+ if ( $contents === false ) {
+ throw new MWException( __METHOD__.": script file not found: \"$localPath\"" );
+ }
+ $js .= $contents . "\n";
+ }
+ return $js;
+ }
+
+ /**
+ * Gets the contents of a list of CSS files.
+ *
+ * @param $styles Array: List of file paths to styles to read, remap and concetenate
+ * @return Array: List of concatenated and remapped CSS data from $styles,
+ * keyed by media type
+ */
+ protected function readStyleFiles( array $styles, $flip ) {
+ if ( empty( $styles ) ) {
+ return array();
+ }
+ $styles = self::collateFilePathListByOption( $styles, 'media', 'all' );
+ foreach ( $styles as $media => $files ) {
+ $uniqueFiles = array_unique( $files );
+ $styles[$media] = implode(
+ "\n",
+ array_map(
+ array( $this, 'readStyleFile' ),
+ $uniqueFiles,
+ array_fill( 0, count( $uniqueFiles ), $flip )
+ )
+ );
+ }
+ return $styles;
+ }
+
+ /**
+ * Reads a style file.
+ *
+ * This method can be used as a callback for array_map()
+ *
+ * @param $path String: File path of script file to read
+ * @return String: CSS data in script file
+ */
+ protected function readStyleFile( $path, $flip ) {
+ $localPath = $this->getLocalPath( $path );
+ $style = file_get_contents( $localPath );
+ if ( $style === false ) {
+ throw new MWException( __METHOD__.": style file not found: \"$localPath\"" );
+ }
+ if ( $flip ) {
+ $style = CSSJanus::transform( $style, true, false );
+ }
+ $dir = $this->getLocalPath( dirname( $path ) );
+ $remoteDir = $this->getRemotePath( dirname( $path ) );
+ // Get and register local file references
+ $this->localFileRefs = array_merge(
+ $this->localFileRefs,
+ CSSMin::getLocalFileReferences( $style, $dir ) );
+ return CSSMin::remap(
+ $style, $dir, $remoteDir, true
+ );
+ }
+}
diff --git a/includes/resourceloader/ResourceLoaderModule.php b/includes/resourceloader/ResourceLoaderModule.php
new file mode 100644
index 00000000..77d230c9
--- /dev/null
+++ b/includes/resourceloader/ResourceLoaderModule.php
@@ -0,0 +1,239 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Abstraction for resource loader modules, with name registration and maxage functionality.
+ */
+abstract class ResourceLoaderModule {
+
+ /* Protected Members */
+
+ protected $name = null;
+
+ // In-object cache for file dependencies
+ protected $fileDeps = array();
+ // In-object cache for message blob mtime
+ protected $msgBlobMtime = array();
+
+ /* Methods */
+
+ /**
+ * Get this module's name. This is set when the module is registered
+ * with ResourceLoader::register()
+ *
+ * @return Mixed: Name (string) or null if no name was set
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Set this module's name. This is called by ResourceLodaer::register()
+ * when registering the module. Other code should not call this.
+ *
+ * @param $name String: Name
+ */
+ public function setName( $name ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Get whether CSS for this module should be flipped
+ */
+ public function getFlip( $context ) {
+ return $context->getDirection() === 'rtl';
+ }
+
+ /**
+ * Get all JS for this module for a given language and skin.
+ * Includes all relevant JS except loader scripts.
+ *
+ * @param $context ResourceLoaderContext: Context object
+ * @return String: JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ // Stub, override expected
+ return '';
+ }
+
+ /**
+ * Get all CSS for this module for a given skin.
+ *
+ * @param $context ResourceLoaderContext: Context object
+ * @return Array: List of CSS strings keyed by media type
+ */
+ public function getStyles( ResourceLoaderContext $context ) {
+ // Stub, override expected
+ return '';
+ }
+
+ /**
+ * Get the messages needed for this module.
+ *
+ * To get a JSON blob with messages, use MessageBlobStore::get()
+ *
+ * @return Array: List of message keys. Keys may occur more than once
+ */
+ public function getMessages() {
+ // Stub, override expected
+ return array();
+ }
+
+ /**
+ * Get the group this module is in.
+ *
+ * @return String: Group name
+ */
+ public function getGroup() {
+ // Stub, override expected
+ return null;
+ }
+
+ /**
+ * Get the loader JS for this module, if set.
+ *
+ * @return Mixed: JavaScript loader code as a string or boolean false if no custom loader set
+ */
+ public function getLoaderScript() {
+ // Stub, override expected
+ return false;
+ }
+
+ /**
+ * Get a list of modules this module depends on.
+ *
+ * Dependency information is taken into account when loading a module
+ * on the client side. When adding a module on the server side,
+ * dependency information is NOT taken into account and YOU are
+ * responsible for adding dependent modules as well. If you don't do
+ * this, the client side loader will send a second request back to the
+ * server to fetch the missing modules, which kind of defeats the
+ * purpose of the resource loader.
+ *
+ * To add dependencies dynamically on the client side, use a custom
+ * loader script, see getLoaderScript()
+ * @return Array: List of module names as strings
+ */
+ public function getDependencies() {
+ // Stub, override expected
+ return array();
+ }
+
+ /**
+ * Get the files this module depends on indirectly for a given skin.
+ * Currently these are only image files referenced by the module's CSS.
+ *
+ * @param $skin String: Skin name
+ * @return Array: List of files
+ */
+ public function getFileDependencies( $skin ) {
+ // Try in-object cache first
+ if ( isset( $this->fileDeps[$skin] ) ) {
+ return $this->fileDeps[$skin];
+ }
+
+ $dbr = wfGetDB( DB_SLAVE );
+ $deps = $dbr->selectField( 'module_deps', 'md_deps', array(
+ 'md_module' => $this->getName(),
+ 'md_skin' => $skin,
+ ), __METHOD__
+ );
+ if ( !is_null( $deps ) ) {
+ $this->fileDeps[$skin] = (array) FormatJson::decode( $deps, true );
+ } else {
+ $this->fileDeps[$skin] = array();
+ }
+ return $this->fileDeps[$skin];
+ }
+
+ /**
+ * Set preloaded file dependency information. Used so we can load this
+ * information for all modules at once.
+ * @param $skin String: Skin name
+ * @param $deps Array: Array of file names
+ */
+ public function setFileDependencies( $skin, $deps ) {
+ $this->fileDeps[$skin] = $deps;
+ }
+
+ /**
+ * Get the last modification timestamp of the message blob for this
+ * module in a given language.
+ * @param $lang String: Language code
+ * @return Integer: UNIX timestamp, or 0 if no blob found
+ */
+ public function getMsgBlobMtime( $lang ) {
+ if ( !isset( $this->msgBlobMtime[$lang] ) ) {
+ if ( !count( $this->getMessages() ) )
+ return 0;
+
+ $dbr = wfGetDB( DB_SLAVE );
+ $msgBlobMtime = $dbr->selectField( 'msg_resource', 'mr_timestamp', array(
+ 'mr_resource' => $this->getName(),
+ 'mr_lang' => $lang
+ ), __METHOD__
+ );
+ $this->msgBlobMtime[$lang] = $msgBlobMtime ? wfTimestamp( TS_UNIX, $msgBlobMtime ) : 0;
+ }
+ return $this->msgBlobMtime[$lang];
+ }
+
+ /**
+ * Set a preloaded message blob last modification timestamp. Used so we
+ * can load this information for all modules at once.
+ * @param $lang String: Language code
+ * @param $mtime Integer: UNIX timestamp or 0 if there is no such blob
+ */
+ public function setMsgBlobMtime( $lang, $mtime ) {
+ $this->msgBlobMtime[$lang] = $mtime;
+ }
+
+ /* Abstract Methods */
+
+ /**
+ * Get this module's last modification timestamp for a given
+ * combination of language, skin and debug mode flag. This is typically
+ * the highest of each of the relevant components' modification
+ * timestamps. Whenever anything happens that changes the module's
+ * contents for these parameters, the mtime should increase.
+ *
+ * @param $context ResourceLoaderContext: Context object
+ * @return Integer: UNIX timestamp
+ */
+ public function getModifiedTime( ResourceLoaderContext $context ) {
+ // 0 would mean now
+ return 1;
+ }
+
+ /**
+ * Check whether this module is known to be empty. If a child class
+ * has an easy and cheap way to determine that this module is
+ * definitely going to be empty, it should override this method to
+ * return true in that case. Callers may optimize the request for this
+ * module away if this function returns true.
+ * @param $context ResourceLoaderContext: Context object
+ * @return Boolean
+ */
+ public function isKnownEmpty( ResourceLoaderContext $context ) {
+ return false;
+ }
+}
diff --git a/includes/resourceloader/ResourceLoaderSiteModule.php b/includes/resourceloader/ResourceLoaderSiteModule.php
new file mode 100644
index 00000000..977d16bb
--- /dev/null
+++ b/includes/resourceloader/ResourceLoaderSiteModule.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for site customizations
+ */
+class ResourceLoaderSiteModule extends ResourceLoaderWikiModule {
+
+ /* Protected Methods */
+
+ /**
+ * Gets list of pages used by this module
+ *
+ * @return Array: List of pages
+ */
+ protected function getPages( ResourceLoaderContext $context ) {
+ global $wgHandheldStyle;
+
+ $pages = array(
+ 'MediaWiki:Common.js' => array( 'type' => 'script' ),
+ 'MediaWiki:Common.css' => array( 'type' => 'style' ),
+ 'MediaWiki:' . ucfirst( $context->getSkin() ) . '.js' => array( 'type' => 'script' ),
+ 'MediaWiki:' . ucfirst( $context->getSkin() ) . '.css' => array( 'type' => 'style' ),
+ 'MediaWiki:Print.css' => array( 'type' => 'style', 'media' => 'print' ),
+ );
+ if ( $wgHandheldStyle ) {
+ $pages['MediaWiki:Handheld.css'] = array(
+ 'type' => 'style',
+ 'media' => 'handheld' );
+ }
+ return $pages;
+ }
+
+ /* Methods */
+
+ /**
+ * Gets group name
+ *
+ * @return String: Name of group
+ */
+ public function getGroup() {
+ return 'site';
+ }
+}
diff --git a/includes/resourceloader/ResourceLoaderStartUpModule.php b/includes/resourceloader/ResourceLoaderStartUpModule.php
new file mode 100644
index 00000000..2a3ba343
--- /dev/null
+++ b/includes/resourceloader/ResourceLoaderStartUpModule.php
@@ -0,0 +1,225 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+class ResourceLoaderStartUpModule extends ResourceLoaderModule {
+
+ /* Protected Members */
+
+ protected $modifiedTime = array();
+
+ /* Protected Methods */
+
+ protected function getConfig( $context ) {
+ global $wgLoadScript, $wgScript, $wgStylePath, $wgScriptExtension,
+ $wgArticlePath, $wgScriptPath, $wgServer, $wgContLang,
+ $wgVariantArticlePath, $wgActionPaths, $wgUseAjax, $wgVersion,
+ $wgEnableAPI, $wgEnableWriteAPI, $wgDBname, $wgEnableMWSuggest,
+ $wgSitename, $wgFileExtensions, $wgExtensionAssetsPath,
+ $wgResourceLoaderMaxQueryLength;
+
+ // Pre-process information
+ $separatorTransTable = $wgContLang->separatorTransformTable();
+ $separatorTransTable = $separatorTransTable ? $separatorTransTable : array();
+ $compactSeparatorTransTable = array(
+ implode( "\t", array_keys( $separatorTransTable ) ),
+ implode( "\t", $separatorTransTable ),
+ );
+ $digitTransTable = $wgContLang->digitTransformTable();
+ $digitTransTable = $digitTransTable ? $digitTransTable : array();
+ $compactDigitTransTable = array(
+ implode( "\t", array_keys( $digitTransTable ) ),
+ implode( "\t", $digitTransTable ),
+ );
+ $mainPage = Title::newMainPage();
+
+ // Build list of variables
+ $vars = array(
+ 'wgLoadScript' => $wgLoadScript,
+ 'debug' => $context->getDebug(),
+ 'skin' => $context->getSkin(),
+ 'stylepath' => $wgStylePath,
+ 'wgUrlProtocols' => wfUrlProtocols(),
+ 'wgArticlePath' => $wgArticlePath,
+ 'wgScriptPath' => $wgScriptPath,
+ 'wgScriptExtension' => $wgScriptExtension,
+ 'wgScript' => $wgScript,
+ 'wgVariantArticlePath' => $wgVariantArticlePath,
+ 'wgActionPaths' => $wgActionPaths,
+ 'wgServer' => $wgServer,
+ 'wgUserLanguage' => $context->getLanguage(),
+ 'wgContentLanguage' => $wgContLang->getCode(),
+ 'wgVersion' => $wgVersion,
+ 'wgEnableAPI' => $wgEnableAPI,
+ 'wgEnableWriteAPI' => $wgEnableWriteAPI,
+ 'wgSeparatorTransformTable' => $compactSeparatorTransTable,
+ 'wgDigitTransformTable' => $compactDigitTransTable,
+ 'wgMainPageTitle' => $mainPage ? $mainPage->getPrefixedText() : null,
+ 'wgFormattedNamespaces' => $wgContLang->getFormattedNamespaces(),
+ 'wgNamespaceIds' => $wgContLang->getNamespaceIds(),
+ 'wgSiteName' => $wgSitename,
+ 'wgFileExtensions' => array_values( $wgFileExtensions ),
+ 'wgDBname' => $wgDBname,
+ 'wgExtensionAssetsPath' => $wgExtensionAssetsPath,
+ 'wgResourceLoaderMaxQueryLength' => $wgResourceLoaderMaxQueryLength,
+ );
+ if ( $wgContLang->hasVariants() ) {
+ $vars['wgUserVariant'] = $wgContLang->getPreferredVariant();
+ }
+ if ( $wgUseAjax && $wgEnableMWSuggest ) {
+ $vars['wgMWSuggestTemplate'] = SearchEngine::getMWSuggestTemplate();
+ }
+
+ wfRunHooks( 'ResourceLoaderGetConfigVars', array( &$vars ) );
+
+ return $vars;
+ }
+
+ /**
+ * Gets registration code for all modules
+ *
+ * @param $context ResourceLoaderContext object
+ * @return String: JavaScript code for registering all modules with the client loader
+ */
+ public static function getModuleRegistrations( ResourceLoaderContext $context ) {
+ global $wgCacheEpoch;
+ wfProfileIn( __METHOD__ );
+
+ $out = '';
+ $registrations = array();
+ $resourceLoader = $context->getResourceLoader();
+ foreach ( $resourceLoader->getModuleNames() as $name ) {
+ $module = $resourceLoader->getModule( $name );
+ // Support module loader scripts
+ $loader = $module->getLoaderScript();
+ if ( $loader !== false ) {
+ $deps = $module->getDependencies();
+ $group = $module->getGroup();
+ $version = wfTimestamp( TS_ISO_8601_BASIC,
+ round( $module->getModifiedTime( $context ), -2 ) );
+ $out .= ResourceLoader::makeCustomLoaderScript( $name, $version, $deps, $group, $loader );
+ }
+ // Automatically register module
+ else {
+ $mtime = max( $module->getModifiedTime( $context ), wfTimestamp( TS_UNIX, $wgCacheEpoch ) );
+ // Modules without dependencies or a group pass two arguments (name, timestamp) to
+ // mediaWiki.loader.register()
+ if ( !count( $module->getDependencies() && $module->getGroup() === null ) ) {
+ $registrations[] = array( $name, $mtime );
+ }
+ // Modules with dependencies but no group pass three arguments
+ // (name, timestamp, dependencies) to mediaWiki.loader.register()
+ else if ( $module->getGroup() === null ) {
+ $registrations[] = array(
+ $name, $mtime, $module->getDependencies() );
+ }
+ // Modules with dependencies pass four arguments (name, timestamp, dependencies, group)
+ // to mediaWiki.loader.register()
+ else {
+ $registrations[] = array(
+ $name, $mtime, $module->getDependencies(), $module->getGroup() );
+ }
+ }
+ }
+ $out .= ResourceLoader::makeLoaderRegisterScript( $registrations );
+
+ wfProfileOut( __METHOD__ );
+ return $out;
+ }
+
+ /* Methods */
+
+ public function getScript( ResourceLoaderContext $context ) {
+ global $IP, $wgLoadScript;
+
+ $out = file_get_contents( "$IP/resources/startup.js" );
+ if ( $context->getOnly() === 'scripts' ) {
+ // Build load query for jquery and mediawiki modules
+ $query = array(
+ 'modules' => implode( '|', array( 'jquery', 'mediawiki' ) ),
+ 'only' => 'scripts',
+ 'lang' => $context->getLanguage(),
+ 'skin' => $context->getSkin(),
+ 'debug' => $context->getDebug() ? 'true' : 'false',
+ 'version' => wfTimestamp( TS_ISO_8601_BASIC, round( max(
+ $context->getResourceLoader()->getModule( 'jquery' )->getModifiedTime( $context ),
+ $context->getResourceLoader()->getModule( 'mediawiki' )->getModifiedTime( $context )
+ ), -2 ) )
+ );
+ // Ensure uniform query order
+ ksort( $query );
+
+ // Startup function
+ $configuration = $this->getConfig( $context );
+ $registrations = self::getModuleRegistrations( $context );
+ $out .= "var startUp = function() {\n" .
+ "\t$registrations\n" .
+ "\t" . Xml::encodeJsCall( 'mediaWiki.config.set', array( $configuration ) ) .
+ "};\n";
+
+ // Conditional script injection
+ $scriptTag = Html::linkedScript( $wgLoadScript . '?' . wfArrayToCGI( $query ) );
+ $out .= "if ( isCompatible() ) {\n" .
+ "\t" . Xml::encodeJsCall( 'document.write', array( $scriptTag ) ) .
+ "}\n" .
+ "delete isCompatible;";
+ }
+
+ return $out;
+ }
+
+ public function getModifiedTime( ResourceLoaderContext $context ) {
+ global $IP, $wgCacheEpoch;
+
+ $hash = $context->getHash();
+ if ( isset( $this->modifiedTime[$hash] ) ) {
+ return $this->modifiedTime[$hash];
+ }
+
+ // Call preloadModuleInfo() on ALL modules as we're about
+ // to call getModifiedTime() on all of them
+ $loader = $context->getResourceLoader();
+ $loader->preloadModuleInfo( $loader->getModuleNames(), $context );
+
+ $this->modifiedTime[$hash] = filemtime( "$IP/resources/startup.js" );
+ // ATTENTION!: Because of the line above, this is not going to cause
+ // infinite recursion - think carefully before making changes to this
+ // code!
+ $time = wfTimestamp( TS_UNIX, $wgCacheEpoch );
+ foreach ( $loader->getModuleNames() as $name ) {
+ $module = $loader->getModule( $name );
+ $time = max( $time, $module->getModifiedTime( $context ) );
+ }
+ return $this->modifiedTime[$hash] = $time;
+ }
+
+ public function getFlip( $context ) {
+ global $wgContLang;
+
+ return $wgContLang->getDir() !== $context->getDirection();
+ }
+
+ /* Methods */
+
+ public function getGroup() {
+ return 'startup';
+ }
+}
diff --git a/includes/resourceloader/ResourceLoaderUserModule.php b/includes/resourceloader/ResourceLoaderUserModule.php
new file mode 100644
index 00000000..c7186653
--- /dev/null
+++ b/includes/resourceloader/ResourceLoaderUserModule.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for user customizations
+ */
+class ResourceLoaderUserModule extends ResourceLoaderWikiModule {
+
+ /* Protected Methods */
+
+ protected function getPages( ResourceLoaderContext $context ) {
+ if ( $context->getUser() ) {
+ $username = $context->getUser();
+ return array(
+ "User:$username/common.js" => array( 'type' => 'script' ),
+ "User:$username/" . $context->getSkin() . '.js' =>
+ array( 'type' => 'script' ),
+ "User:$username/common.css" => array( 'type' => 'style' ),
+ "User:$username/" . $context->getSkin() . '.css' =>
+ array( 'type' => 'style' ),
+ );
+ }
+ return array();
+ }
+
+ /* Methods */
+
+ public function getGroup() {
+ return 'user';
+ }
+}
diff --git a/includes/resourceloader/ResourceLoaderUserOptionsModule.php b/includes/resourceloader/ResourceLoaderUserOptionsModule.php
new file mode 100644
index 00000000..ae654b8f
--- /dev/null
+++ b/includes/resourceloader/ResourceLoaderUserOptionsModule.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for user preference customizations
+ */
+class ResourceLoaderUserOptionsModule extends ResourceLoaderModule {
+
+ /* Protected Members */
+
+ protected $modifiedTime = array();
+
+ /* Methods */
+
+ public function getModifiedTime( ResourceLoaderContext $context ) {
+ $hash = $context->getHash();
+ if ( isset( $this->modifiedTime[$hash] ) ) {
+ return $this->modifiedTime[$hash];
+ }
+
+ global $wgUser;
+
+ if ( $context->getUser() === $wgUser->getName() ) {
+ return $this->modifiedTime[$hash] = $wgUser->getTouched();
+ } else {
+ return 1;
+ }
+ }
+
+ /**
+ * Fetch the context's user options, or if it doesn't match current user,
+ * the default options.
+ *
+ * @param $context ResourceLoaderContext: Context object
+ * @return Array: List of user options keyed by option name
+ */
+ protected function contextUserOptions( ResourceLoaderContext $context ) {
+ global $wgUser;
+
+ // Verify identity -- this is a private module
+ if ( $context->getUser() === $wgUser->getName() ) {
+ return $wgUser->getOptions();
+ } else {
+ return User::getDefaultOptions();
+ }
+ }
+
+ public function getScript( ResourceLoaderContext $context ) {
+ return Xml::encodeJsCall( 'mediaWiki.user.options.set',
+ array( $this->contextUserOptions( $context ) ) );
+ }
+
+ public function getStyles( ResourceLoaderContext $context ) {
+ global $wgAllowUserCssPrefs;
+
+ if ( $wgAllowUserCssPrefs ) {
+ $options = $this->contextUserOptions( $context );
+
+ // Build CSS rules
+ $rules = array();
+ if ( $options['underline'] < 2 ) {
+ $rules[] = "a { text-decoration: " .
+ ( $options['underline'] ? 'underline' : 'none' ) . "; }";
+ }
+ if ( $options['highlightbroken'] ) {
+ $rules[] = "a.new, #quickbar a.new { color: #ba0000; }\n";
+ } else {
+ $rules[] = "a.new, #quickbar a.new, a.stub, #quickbar a.stub { color: inherit; }";
+ $rules[] = "a.new:after, #quickbar a.new:after { content: '?'; color: #ba0000; }";
+ $rules[] = "a.stub:after, #quickbar a.stub:after { content: '!'; color: #772233; }";
+ }
+ if ( $options['justify'] ) {
+ $rules[] = "#article, #bodyContent, #mw_content { text-align: justify; }\n";
+ }
+ if ( !$options['showtoc'] ) {
+ $rules[] = "#toc { display: none; }\n";
+ }
+ if ( !$options['editsection'] ) {
+ $rules[] = ".editsection { display: none; }\n";
+ }
+ if ( $options['editfont'] !== 'default' ) {
+ $rules[] = "textarea { font-family: {$options['editfont']}; }\n";
+ }
+ $style = implode( "\n", $rules );
+ if ( $this->getFlip( $context ) ) {
+ $style = CSSJanus::transform( $style, true, false );
+ }
+ return array( 'all' => $style );
+ }
+ return array();
+ }
+
+ public function getFlip( $context ) {
+ global $wgContLang;
+
+ return $wgContLang->getDir() !== $context->getDirection();
+ }
+
+ public function getGroup() {
+ return 'private';
+ }
+}
diff --git a/includes/resourceloader/ResourceLoaderWikiModule.php b/includes/resourceloader/ResourceLoaderWikiModule.php
new file mode 100644
index 00000000..93e66eb0
--- /dev/null
+++ b/includes/resourceloader/ResourceLoaderWikiModule.php
@@ -0,0 +1,171 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+defined( 'MEDIAWIKI' ) || die( 1 );
+
+/**
+ * Abstraction for resource loader modules which pull from wiki pages
+ *
+ * This can only be used for wiki pages in the MediaWiki and User namespaces,
+ * because of its dependence on the functionality of
+ * Title::isValidCssJsSubpage.
+ */
+abstract class ResourceLoaderWikiModule extends ResourceLoaderModule {
+
+ /* Protected Members */
+
+ // In-object cache for title mtimes
+ protected $titleMtimes = array();
+
+ /* Abstract Protected Methods */
+
+ abstract protected function getPages( ResourceLoaderContext $context );
+
+ /* Protected Methods */
+
+ protected function getContent( $title ) {
+ if ( $title->getNamespace() === NS_MEDIAWIKI ) {
+ $dbkey = $title->getDBkey();
+ return wfEmptyMsg( $dbkey ) ? '' : wfMsgExt( $dbkey, 'content' );
+ }
+ if ( !$title->isCssJsSubpage() ) {
+ return null;
+ }
+ $revision = Revision::newFromTitle( $title );
+ if ( !$revision ) {
+ return null;
+ }
+ return $revision->getRawText();
+ }
+
+ /* Methods */
+
+ public function getScript( ResourceLoaderContext $context ) {
+ $scripts = '';
+ foreach ( $this->getPages( $context ) as $titleText => $options ) {
+ if ( $options['type'] !== 'script' ) {
+ continue;
+ }
+ $title = Title::newFromText( $titleText );
+ if ( !$title ) {
+ continue;
+ }
+ $script = $this->getContent( $title );
+ if ( strval( $script ) !== '' ) {
+ if ( strpos( $titleText, '*/' ) === false ) {
+ $scripts .= "/* $titleText */\n";
+ }
+ $scripts .= $script . "\n";
+ }
+ }
+ return $scripts;
+ }
+
+ public function getStyles( ResourceLoaderContext $context ) {
+ global $wgScriptPath;
+
+ $styles = array();
+ foreach ( $this->getPages( $context ) as $titleText => $options ) {
+ if ( $options['type'] !== 'style' ) {
+ continue;
+ }
+ $title = Title::newFromText( $titleText );
+ if ( !$title ) {
+ continue;
+ }
+ $media = isset( $options['media'] ) ? $options['media'] : 'all';
+ $style = $this->getContent( $title );
+ if ( strval( $style ) === '' ) {
+ continue;
+ }
+ if ( $this->getFlip( $context ) ) {
+ $style = CSSJanus::transform( $style, true, false );
+ }
+ $style = CSSMin::remap( $style, false, $wgScriptPath, true );
+ if ( !isset( $styles[$media] ) ) {
+ $styles[$media] = '';
+ }
+ if ( strpos( $titleText, '*/' ) === false ) {
+ $styles[$media] .= "/* $titleText */\n";
+ }
+ $styles[$media] .= $style . "\n";
+ }
+ return $styles;
+ }
+
+ public function getModifiedTime( ResourceLoaderContext $context ) {
+ $modifiedTime = 1; // wfTimestamp() interprets 0 as "now"
+ $mtimes = $this->getTitleMtimes( $context );
+ if ( count( $mtimes ) ) {
+ $modifiedTime = max( $modifiedTime, max( $mtimes ) );
+ }
+ return $modifiedTime;
+ }
+
+ public function isKnownEmpty( ResourceLoaderContext $context ) {
+ return count( $this->getTitleMtimes( $context ) ) == 0;
+ }
+
+ /**
+ * @param $context ResourceLoaderContext
+ * @return bool
+ */
+ public function getFlip( $context ) {
+ global $wgContLang;
+
+ return $wgContLang->getDir() !== $context->getDirection();
+ }
+
+ /**
+ * Get the modification times of all titles that would be loaded for
+ * a given context.
+ * @param $context ResourceLoaderContext: Context object
+ * @return array( prefixed DB key => UNIX timestamp ), nonexistent titles are dropped
+ */
+ protected function getTitleMtimes( ResourceLoaderContext $context ) {
+ $hash = $context->getHash();
+ if ( isset( $this->titleMtimes[$hash] ) ) {
+ return $this->titleMtimes[$hash];
+ }
+
+ $this->titleMtimes[$hash] = array();
+ $batch = new LinkBatch;
+ foreach ( $this->getPages( $context ) as $titleText => $options ) {
+ $batch->addObj( Title::newFromText( $titleText ) );
+ }
+
+ if ( !$batch->isEmpty() ) {
+ $dbr = wfGetDB( DB_SLAVE );
+ $res = $dbr->select( 'page',
+ array( 'page_namespace', 'page_title', 'page_touched' ),
+ $batch->constructSet( 'page', $dbr ),
+ __METHOD__
+ );
+ foreach ( $res as $row ) {
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ $this->titleMtimes[$hash][$title->getPrefixedDBkey()] =
+ wfTimestamp( TS_UNIX, $row->page_touched );
+ }
+ }
+ return $this->titleMtimes[$hash];
+ }
+}