Merge "objectcache: add getMultiWithUnionSetCallback() method"
[lhc/web/wiklou.git] / includes / resourceloader / ResourceLoader.php
1 <?php
2 /**
3 * Base class for resource loading system.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @author Roan Kattouw
22 * @author Trevor Parscal
23 */
24
25 use MediaWiki\MediaWikiServices;
26 use Psr\Log\LoggerAwareInterface;
27 use Psr\Log\LoggerInterface;
28 use Psr\Log\NullLogger;
29 use WrappedString\WrappedString;
30 use Wikimedia\Rdbms\DBConnectionError;
31
32 /**
33 * Dynamic JavaScript and CSS resource loading system.
34 *
35 * Most of the documentation is on the MediaWiki documentation wiki starting at:
36 * https://www.mediawiki.org/wiki/ResourceLoader
37 */
38 class ResourceLoader implements LoggerAwareInterface {
39 /** @var int */
40 protected static $filterCacheVersion = 7;
41
42 /** @var bool */
43 protected static $debugMode = null;
44
45 /** @var array */
46 private $lessVars = null;
47
48 /**
49 * Module name/ResourceLoaderModule object pairs
50 * @var array
51 */
52 protected $modules = [];
53
54 /**
55 * Associative array mapping module name to info associative array
56 * @var array
57 */
58 protected $moduleInfos = [];
59
60 /** @var Config $config */
61 protected $config;
62
63 /**
64 * Associative array mapping framework ids to a list of names of test suite modules
65 * like [ 'qunit' => [ 'mediawiki.tests.qunit.suites', 'ext.foo.tests', ... ], ... ]
66 * @var array
67 */
68 protected $testModuleNames = [];
69
70 /**
71 * E.g. [ 'source-id' => 'http://.../load.php' ]
72 * @var array
73 */
74 protected $sources = [];
75
76 /**
77 * Errors accumulated during current respond() call.
78 * @var array
79 */
80 protected $errors = [];
81
82 /**
83 * @var MessageBlobStore
84 */
85 protected $blobStore;
86
87 /**
88 * @var LoggerInterface
89 */
90 private $logger;
91
92 /** @var string JavaScript / CSS pragma to disable minification. **/
93 const FILTER_NOMIN = '/*@nomin*/';
94
95 /**
96 * Load information stored in the database about modules.
97 *
98 * This method grabs modules dependencies from the database and updates modules
99 * objects.
100 *
101 * This is not inside the module code because it is much faster to
102 * request all of the information at once than it is to have each module
103 * requests its own information. This sacrifice of modularity yields a substantial
104 * performance improvement.
105 *
106 * @param array $moduleNames List of module names to preload information for
107 * @param ResourceLoaderContext $context Context to load the information within
108 */
109 public function preloadModuleInfo( array $moduleNames, ResourceLoaderContext $context ) {
110 if ( !$moduleNames ) {
111 // Or else Database*::select() will explode, plus it's cheaper!
112 return;
113 }
114 $dbr = wfGetDB( DB_REPLICA );
115 $skin = $context->getSkin();
116 $lang = $context->getLanguage();
117
118 // Batched version of ResourceLoaderModule::getFileDependencies
119 $vary = "$skin|$lang";
120 $res = $dbr->select( 'module_deps', [ 'md_module', 'md_deps' ], [
121 'md_module' => $moduleNames,
122 'md_skin' => $vary,
123 ], __METHOD__
124 );
125
126 // Prime in-object cache for file dependencies
127 $modulesWithDeps = [];
128 foreach ( $res as $row ) {
129 $module = $this->getModule( $row->md_module );
130 if ( $module ) {
131 $module->setFileDependencies( $context, ResourceLoaderModule::expandRelativePaths(
132 FormatJson::decode( $row->md_deps, true )
133 ) );
134 $modulesWithDeps[] = $row->md_module;
135 }
136 }
137 // Register the absence of a dependency row too
138 foreach ( array_diff( $moduleNames, $modulesWithDeps ) as $name ) {
139 $module = $this->getModule( $name );
140 if ( $module ) {
141 $this->getModule( $name )->setFileDependencies( $context, [] );
142 }
143 }
144
145 // Batched version of ResourceLoaderWikiModule::getTitleInfo
146 ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $moduleNames );
147
148 // Prime in-object cache for message blobs for modules with messages
149 $modules = [];
150 foreach ( $moduleNames as $name ) {
151 $module = $this->getModule( $name );
152 if ( $module && $module->getMessages() ) {
153 $modules[$name] = $module;
154 }
155 }
156 $store = $this->getMessageBlobStore();
157 $blobs = $store->getBlobs( $modules, $lang );
158 foreach ( $blobs as $name => $blob ) {
159 $modules[$name]->setMessageBlob( $blob, $lang );
160 }
161 }
162
163 /**
164 * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
165 *
166 * Available filters are:
167 *
168 * - minify-js \see JavaScriptMinifier::minify
169 * - minify-css \see CSSMin::minify
170 *
171 * If $data is empty, only contains whitespace or the filter was unknown,
172 * $data is returned unmodified.
173 *
174 * @param string $filter Name of filter to run
175 * @param string $data Text to filter, such as JavaScript or CSS text
176 * @param array $options Keys:
177 * - (bool) cache: Whether to allow caching this data. Default: true.
178 * @return string Filtered data, or a comment containing an error message
179 */
180 public static function filter( $filter, $data, array $options = [] ) {
181 if ( strpos( $data, ResourceLoader::FILTER_NOMIN ) !== false ) {
182 return $data;
183 }
184
185 if ( isset( $options['cache'] ) && $options['cache'] === false ) {
186 return self::applyFilter( $filter, $data );
187 }
188
189 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
190 $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
191
192 $key = $cache->makeGlobalKey(
193 'resourceloader',
194 'filter',
195 $filter,
196 self::$filterCacheVersion, md5( $data )
197 );
198
199 $result = $cache->get( $key );
200 if ( $result === false ) {
201 $stats->increment( "resourceloader_cache.$filter.miss" );
202 $result = self::applyFilter( $filter, $data );
203 $cache->set( $key, $result, 24 * 3600 );
204 } else {
205 $stats->increment( "resourceloader_cache.$filter.hit" );
206 }
207 if ( $result === null ) {
208 // Cached failure
209 $result = $data;
210 }
211
212 return $result;
213 }
214
215 private static function applyFilter( $filter, $data ) {
216 $data = trim( $data );
217 if ( $data ) {
218 try {
219 $data = ( $filter === 'minify-css' )
220 ? CSSMin::minify( $data )
221 : JavaScriptMinifier::minify( $data );
222 } catch ( Exception $e ) {
223 MWExceptionHandler::logException( $e );
224 return null;
225 }
226 }
227 return $data;
228 }
229
230 /* Methods */
231
232 /**
233 * Register core modules and runs registration hooks.
234 * @param Config $config [optional]
235 * @param LoggerInterface $logger [optional]
236 */
237 public function __construct( Config $config = null, LoggerInterface $logger = null ) {
238 global $IP;
239
240 $this->logger = $logger ?: new NullLogger();
241
242 if ( !$config ) {
243 $this->logger->debug( __METHOD__ . ' was called without providing a Config instance' );
244 $config = MediaWikiServices::getInstance()->getMainConfig();
245 }
246 $this->config = $config;
247
248 // Add 'local' source first
249 $this->addSource( 'local', $config->get( 'LoadScript' ) );
250
251 // Add other sources
252 $this->addSource( $config->get( 'ResourceLoaderSources' ) );
253
254 // Register core modules
255 $this->register( include "$IP/resources/Resources.php" );
256 // Register extension modules
257 $this->register( $config->get( 'ResourceModules' ) );
258
259 // Avoid PHP 7.1 warning from passing $this by reference
260 $rl = $this;
261 Hooks::run( 'ResourceLoaderRegisterModules', [ &$rl ] );
262
263 if ( $config->get( 'EnableJavaScriptTest' ) === true ) {
264 $this->registerTestModules();
265 }
266
267 $this->setMessageBlobStore( new MessageBlobStore( $this, $this->logger ) );
268 }
269
270 /**
271 * @return Config
272 */
273 public function getConfig() {
274 return $this->config;
275 }
276
277 /**
278 * @since 1.26
279 * @param LoggerInterface $logger
280 */
281 public function setLogger( LoggerInterface $logger ) {
282 $this->logger = $logger;
283 }
284
285 /**
286 * @since 1.27
287 * @return LoggerInterface
288 */
289 public function getLogger() {
290 return $this->logger;
291 }
292
293 /**
294 * @since 1.26
295 * @return MessageBlobStore
296 */
297 public function getMessageBlobStore() {
298 return $this->blobStore;
299 }
300
301 /**
302 * @since 1.25
303 * @param MessageBlobStore $blobStore
304 */
305 public function setMessageBlobStore( MessageBlobStore $blobStore ) {
306 $this->blobStore = $blobStore;
307 }
308
309 /**
310 * Register a module with the ResourceLoader system.
311 *
312 * @param mixed $name Name of module as a string or List of name/object pairs as an array
313 * @param array $info Module info array. For backwards compatibility with 1.17alpha,
314 * this may also be a ResourceLoaderModule object. Optional when using
315 * multiple-registration calling style.
316 * @throws MWException If a duplicate module registration is attempted
317 * @throws MWException If a module name contains illegal characters (pipes or commas)
318 * @throws MWException If something other than a ResourceLoaderModule is being registered
319 * @return bool False if there were any errors, in which case one or more modules were
320 * not registered
321 */
322 public function register( $name, $info = null ) {
323 $moduleSkinStyles = $this->config->get( 'ResourceModuleSkinStyles' );
324
325 // Allow multiple modules to be registered in one call
326 $registrations = is_array( $name ) ? $name : [ $name => $info ];
327 foreach ( $registrations as $name => $info ) {
328 // Warn on duplicate registrations
329 if ( isset( $this->moduleInfos[$name] ) ) {
330 // A module has already been registered by this name
331 $this->logger->warning(
332 'ResourceLoader duplicate registration warning. ' .
333 'Another module has already been registered as ' . $name
334 );
335 }
336
337 // Check $name for validity
338 if ( !self::isValidModuleName( $name ) ) {
339 throw new MWException( "ResourceLoader module name '$name' is invalid, "
340 . "see ResourceLoader::isValidModuleName()" );
341 }
342
343 // Attach module
344 if ( $info instanceof ResourceLoaderModule ) {
345 $this->moduleInfos[$name] = [ 'object' => $info ];
346 $info->setName( $name );
347 $this->modules[$name] = $info;
348 } elseif ( is_array( $info ) ) {
349 // New calling convention
350 $this->moduleInfos[$name] = $info;
351 } else {
352 throw new MWException(
353 'ResourceLoader module info type error for module \'' . $name .
354 '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
355 );
356 }
357
358 // Last-minute changes
359
360 // Apply custom skin-defined styles to existing modules.
361 if ( $this->isFileModule( $name ) ) {
362 foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
363 // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
364 if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) {
365 continue;
366 }
367
368 // If $name is preceded with a '+', the defined style files will be added to 'default'
369 // skinStyles, otherwise 'default' will be ignored as it normally would be.
370 if ( isset( $skinStyles[$name] ) ) {
371 $paths = (array)$skinStyles[$name];
372 $styleFiles = [];
373 } elseif ( isset( $skinStyles['+' . $name] ) ) {
374 $paths = (array)$skinStyles['+' . $name];
375 $styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ?
376 (array)$this->moduleInfos[$name]['skinStyles']['default'] :
377 [];
378 } else {
379 continue;
380 }
381
382 // Add new file paths, remapping them to refer to our directories and not use settings
383 // from the module we're modifying, which come from the base definition.
384 list( $localBasePath, $remoteBasePath ) =
385 ResourceLoaderFileModule::extractBasePaths( $skinStyles );
386
387 foreach ( $paths as $path ) {
388 $styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath );
389 }
390
391 $this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles;
392 }
393 }
394 }
395 }
396
397 public function registerTestModules() {
398 global $IP;
399
400 if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
401 throw new MWException( 'Attempt to register JavaScript test modules '
402 . 'but <code>$wgEnableJavaScriptTest</code> is false. '
403 . 'Edit your <code>LocalSettings.php</code> to enable it.' );
404 }
405
406 // Get core test suites
407 $testModules = [];
408 $testModules['qunit'] = [];
409 // Get other test suites (e.g. from extensions)
410 // Avoid PHP 7.1 warning from passing $this by reference
411 $rl = $this;
412 Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
413
414 // Add the testrunner (which configures QUnit) to the dependencies.
415 // Since it must be ready before any of the test suites are executed.
416 foreach ( $testModules['qunit'] as &$module ) {
417 // Make sure all test modules are top-loading so that when QUnit starts
418 // on document-ready, it will run once and finish. If some tests arrive
419 // later (possibly after QUnit has already finished) they will be ignored.
420 $module['position'] = 'top';
421 $module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
422 }
423
424 $testModules['qunit'] =
425 ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
426
427 foreach ( $testModules as $id => $names ) {
428 // Register test modules
429 $this->register( $testModules[$id] );
430
431 // Keep track of their names so that they can be loaded together
432 $this->testModuleNames[$id] = array_keys( $testModules[$id] );
433 }
434 }
435
436 /**
437 * Add a foreign source of modules.
438 *
439 * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z).
440 *
441 * @param array|string $id Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ]
442 * @param string|array $loadUrl load.php url (string), or array with loadUrl key for
443 * backwards-compatibility.
444 * @throws MWException
445 */
446 public function addSource( $id, $loadUrl = null ) {
447 // Allow multiple sources to be registered in one call
448 if ( is_array( $id ) ) {
449 foreach ( $id as $key => $value ) {
450 $this->addSource( $key, $value );
451 }
452 return;
453 }
454
455 // Disallow duplicates
456 if ( isset( $this->sources[$id] ) ) {
457 throw new MWException(
458 'ResourceLoader duplicate source addition error. ' .
459 'Another source has already been registered as ' . $id
460 );
461 }
462
463 // Pre 1.24 backwards-compatibility
464 if ( is_array( $loadUrl ) ) {
465 if ( !isset( $loadUrl['loadScript'] ) ) {
466 throw new MWException(
467 __METHOD__ . ' was passed an array with no "loadScript" key.'
468 );
469 }
470
471 $loadUrl = $loadUrl['loadScript'];
472 }
473
474 $this->sources[$id] = $loadUrl;
475 }
476
477 /**
478 * Get a list of module names.
479 *
480 * @return array List of module names
481 */
482 public function getModuleNames() {
483 return array_keys( $this->moduleInfos );
484 }
485
486 /**
487 * Get a list of test module names for one (or all) frameworks.
488 *
489 * If the given framework id is unknkown, or if the in-object variable is not an array,
490 * then it will return an empty array.
491 *
492 * @param string $framework Get only the test module names for one
493 * particular framework (optional)
494 * @return array
495 */
496 public function getTestModuleNames( $framework = 'all' ) {
497 /** @todo api siteinfo prop testmodulenames modulenames */
498 if ( $framework == 'all' ) {
499 return $this->testModuleNames;
500 } elseif ( isset( $this->testModuleNames[$framework] )
501 && is_array( $this->testModuleNames[$framework] )
502 ) {
503 return $this->testModuleNames[$framework];
504 } else {
505 return [];
506 }
507 }
508
509 /**
510 * Check whether a ResourceLoader module is registered
511 *
512 * @since 1.25
513 * @param string $name
514 * @return bool
515 */
516 public function isModuleRegistered( $name ) {
517 return isset( $this->moduleInfos[$name] );
518 }
519
520 /**
521 * Get the ResourceLoaderModule object for a given module name.
522 *
523 * If an array of module parameters exists but a ResourceLoaderModule object has not
524 * yet been instantiated, this method will instantiate and cache that object such that
525 * subsequent calls simply return the same object.
526 *
527 * @param string $name Module name
528 * @return ResourceLoaderModule|null If module has been registered, return a
529 * ResourceLoaderModule instance. Otherwise, return null.
530 */
531 public function getModule( $name ) {
532 if ( !isset( $this->modules[$name] ) ) {
533 if ( !isset( $this->moduleInfos[$name] ) ) {
534 // No such module
535 return null;
536 }
537 // Construct the requested object
538 $info = $this->moduleInfos[$name];
539 /** @var ResourceLoaderModule $object */
540 if ( isset( $info['object'] ) ) {
541 // Object given in info array
542 $object = $info['object'];
543 } elseif ( isset( $info['factory'] ) ) {
544 $object = call_user_func( $info['factory'], $info );
545 $object->setConfig( $this->getConfig() );
546 $object->setLogger( $this->logger );
547 } else {
548 if ( !isset( $info['class'] ) ) {
549 $class = 'ResourceLoaderFileModule';
550 } else {
551 $class = $info['class'];
552 }
553 /** @var ResourceLoaderModule $object */
554 $object = new $class( $info );
555 $object->setConfig( $this->getConfig() );
556 $object->setLogger( $this->logger );
557 }
558 $object->setName( $name );
559 $this->modules[$name] = $object;
560 }
561
562 return $this->modules[$name];
563 }
564
565 /**
566 * Return whether the definition of a module corresponds to a simple ResourceLoaderFileModule.
567 *
568 * @param string $name Module name
569 * @return bool
570 */
571 protected function isFileModule( $name ) {
572 if ( !isset( $this->moduleInfos[$name] ) ) {
573 return false;
574 }
575 $info = $this->moduleInfos[$name];
576 if ( isset( $info['object'] ) || isset( $info['class'] ) ) {
577 return false;
578 }
579 return true;
580 }
581
582 /**
583 * Get the list of sources.
584 *
585 * @return array Like [ id => load.php url, ... ]
586 */
587 public function getSources() {
588 return $this->sources;
589 }
590
591 /**
592 * Get the URL to the load.php endpoint for the given
593 * ResourceLoader source
594 *
595 * @since 1.24
596 * @param string $source
597 * @throws MWException On an invalid $source name
598 * @return string
599 */
600 public function getLoadScript( $source ) {
601 if ( !isset( $this->sources[$source] ) ) {
602 throw new MWException( "The $source source was never registered in ResourceLoader." );
603 }
604 return $this->sources[$source];
605 }
606
607 /**
608 * @since 1.26
609 * @param string $value
610 * @return string Hash
611 */
612 public static function makeHash( $value ) {
613 $hash = hash( 'fnv132', $value );
614 return Wikimedia\base_convert( $hash, 16, 36, 7 );
615 }
616
617 /**
618 * Add an error to the 'errors' array and log it.
619 *
620 * Should only be called from within respond().
621 *
622 * @since 1.29
623 * @param Exception $e
624 * @param string $msg
625 * @param array $context
626 */
627 protected function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
628 MWExceptionHandler::logException( $e );
629 $this->logger->warning(
630 $msg,
631 $context + [ 'exception' => $e ]
632 );
633 $this->errors[] = self::formatExceptionNoComment( $e );
634 }
635
636 /**
637 * Helper method to get and combine versions of multiple modules.
638 *
639 * @since 1.26
640 * @param ResourceLoaderContext $context
641 * @param string[] $modules List of known module names
642 * @return string Hash
643 */
644 public function getCombinedVersion( ResourceLoaderContext $context, array $moduleNames ) {
645 if ( !$moduleNames ) {
646 return '';
647 }
648 $hashes = array_map( function ( $module ) use ( $context ) {
649 try {
650 return $this->getModule( $module )->getVersionHash( $context );
651 } catch ( Exception $e ) {
652 // If modules fail to compute a version, do still consider the versions
653 // of other modules - don't set an empty string E-Tag for the whole request.
654 // See also T152266 and StartupModule::getModuleRegistrations().
655 $this->outputErrorAndLog( $e,
656 'Calculating version for "{module}" failed: {exception}',
657 [
658 'module' => $module,
659 ]
660 );
661 return '';
662 }
663 }, $moduleNames );
664 return self::makeHash( implode( '', $hashes ) );
665 }
666
667 /**
668 * Get the expected value of the 'version' query parameter.
669 *
670 * This is used by respond() to set a short Cache-Control header for requests with
671 * information newer than the current server has. This avoids pollution of edge caches.
672 * Typically during deployment. (T117587)
673 *
674 * This MUST match return value of `mw.loader#getCombinedVersion()` client-side.
675 *
676 * @since 1.28
677 * @param ResourceLoaderContext $context
678 * @param string[] $modules List of module names
679 * @return string Hash
680 */
681 public function makeVersionQuery( ResourceLoaderContext $context ) {
682 // As of MediaWiki 1.28, the server and client use the same algorithm for combining
683 // version hashes. There is no technical reason for this to be same, and for years the
684 // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
685 // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
686 // query parameter), then this method must continue to match the JS one.
687 $moduleNames = [];
688 foreach ( $context->getModules() as $name ) {
689 if ( !$this->getModule( $name ) ) {
690 // If a versioned request contains a missing module, the version is a mismatch
691 // as the client considered a module (and version) we don't have.
692 return '';
693 }
694 $moduleNames[] = $name;
695 }
696 return $this->getCombinedVersion( $context, $moduleNames );
697 }
698
699 /**
700 * Output a response to a load request, including the content-type header.
701 *
702 * @param ResourceLoaderContext $context Context in which a response should be formed
703 */
704 public function respond( ResourceLoaderContext $context ) {
705 // Buffer output to catch warnings. Normally we'd use ob_clean() on the
706 // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
707 // is used: ob_clean() will clear the GZIP header in that case and it won't come
708 // back for subsequent output, resulting in invalid GZIP. So we have to wrap
709 // the whole thing in our own output buffer to be sure the active buffer
710 // doesn't use ob_gzhandler.
711 // See https://bugs.php.net/bug.php?id=36514
712 ob_start();
713
714 // Find out which modules are missing and instantiate the others
715 $modules = [];
716 $missing = [];
717 foreach ( $context->getModules() as $name ) {
718 $module = $this->getModule( $name );
719 if ( $module ) {
720 // Do not allow private modules to be loaded from the web.
721 // This is a security issue, see T36907.
722 if ( $module->getGroup() === 'private' ) {
723 $this->logger->debug( "Request for private module '$name' denied" );
724 $this->errors[] = "Cannot show private module \"$name\"";
725 continue;
726 }
727 $modules[$name] = $module;
728 } else {
729 $missing[] = $name;
730 }
731 }
732
733 try {
734 // Preload for getCombinedVersion() and for batch makeModuleResponse()
735 $this->preloadModuleInfo( array_keys( $modules ), $context );
736 } catch ( Exception $e ) {
737 $this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' );
738 }
739
740 // Combine versions to propagate cache invalidation
741 $versionHash = '';
742 try {
743 $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
744 } catch ( Exception $e ) {
745 $this->outputErrorAndLog( $e, 'Calculating version hash failed: {exception}' );
746 }
747
748 // See RFC 2616 § 3.11 Entity Tags
749 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
750 $etag = 'W/"' . $versionHash . '"';
751
752 // Try the client-side cache first
753 if ( $this->tryRespondNotModified( $context, $etag ) ) {
754 return; // output handled (buffers cleared)
755 }
756
757 // Use file cache if enabled and available...
758 if ( $this->config->get( 'UseFileCache' ) ) {
759 $fileCache = ResourceFileCache::newFromContext( $context );
760 if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
761 return; // output handled
762 }
763 }
764
765 // Generate a response
766 $response = $this->makeModuleResponse( $context, $modules, $missing );
767
768 // Capture any PHP warnings from the output buffer and append them to the
769 // error list if we're in debug mode.
770 if ( $context->getDebug() ) {
771 $warnings = ob_get_contents();
772 if ( strlen( $warnings ) ) {
773 $this->errors[] = $warnings;
774 }
775 }
776
777 // Save response to file cache unless there are errors
778 if ( isset( $fileCache ) && !$this->errors && !count( $missing ) ) {
779 // Cache single modules and images...and other requests if there are enough hits
780 if ( ResourceFileCache::useFileCache( $context ) ) {
781 if ( $fileCache->isCacheWorthy() ) {
782 $fileCache->saveText( $response );
783 } else {
784 $fileCache->incrMissesRecent( $context->getRequest() );
785 }
786 }
787 }
788
789 $this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
790
791 // Remove the output buffer and output the response
792 ob_end_clean();
793
794 if ( $context->getImageObj() && $this->errors ) {
795 // We can't show both the error messages and the response when it's an image.
796 $response = implode( "\n\n", $this->errors );
797 } elseif ( $this->errors ) {
798 $errorText = implode( "\n\n", $this->errors );
799 $errorResponse = self::makeComment( $errorText );
800 if ( $context->shouldIncludeScripts() ) {
801 $errorResponse .= 'if (window.console && console.error) {'
802 . Xml::encodeJsCall( 'console.error', [ $errorText ] )
803 . "}\n";
804 }
805
806 // Prepend error info to the response
807 $response = $errorResponse . $response;
808 }
809
810 $this->errors = [];
811 echo $response;
812 }
813
814 /**
815 * Send main response headers to the client.
816 *
817 * Deals with Content-Type, CORS (for stylesheets), and caching.
818 *
819 * @param ResourceLoaderContext $context
820 * @param string $etag ETag header value
821 * @param bool $errors Whether there are errors in the response
822 * @return void
823 */
824 protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) {
825 \MediaWiki\HeaderCallback::warnIfHeadersSent();
826 $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
827 // Use a short cache expiry so that updates propagate to clients quickly, if:
828 // - No version specified (shared resources, e.g. stylesheets)
829 // - There were errors (recover quickly)
830 // - Version mismatch (T117587, T47877)
831 if ( is_null( $context->getVersion() )
832 || $errors
833 || $context->getVersion() !== $this->makeVersionQuery( $context )
834 ) {
835 $maxage = $rlMaxage['unversioned']['client'];
836 $smaxage = $rlMaxage['unversioned']['server'];
837 // If a version was specified we can use a longer expiry time since changing
838 // version numbers causes cache misses
839 } else {
840 $maxage = $rlMaxage['versioned']['client'];
841 $smaxage = $rlMaxage['versioned']['server'];
842 }
843 if ( $context->getImageObj() ) {
844 // Output different headers if we're outputting textual errors.
845 if ( $errors ) {
846 header( 'Content-Type: text/plain; charset=utf-8' );
847 } else {
848 $context->getImageObj()->sendResponseHeaders( $context );
849 }
850 } elseif ( $context->getOnly() === 'styles' ) {
851 header( 'Content-Type: text/css; charset=utf-8' );
852 header( 'Access-Control-Allow-Origin: *' );
853 } else {
854 header( 'Content-Type: text/javascript; charset=utf-8' );
855 }
856 // See RFC 2616 § 14.19 ETag
857 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
858 header( 'ETag: ' . $etag );
859 if ( $context->getDebug() ) {
860 // Do not cache debug responses
861 header( 'Cache-Control: private, no-cache, must-revalidate' );
862 header( 'Pragma: no-cache' );
863 } else {
864 header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
865 $exp = min( $maxage, $smaxage );
866 header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
867 }
868 }
869
870 /**
871 * Respond with HTTP 304 Not Modified if appropiate.
872 *
873 * If there's an If-None-Match header, respond with a 304 appropriately
874 * and clear out the output buffer. If the client cache is too old then do nothing.
875 *
876 * @param ResourceLoaderContext $context
877 * @param string $etag ETag header value
878 * @return bool True if HTTP 304 was sent and output handled
879 */
880 protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
881 // See RFC 2616 § 14.26 If-None-Match
882 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
883 $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
884 // Never send 304s in debug mode
885 if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
886 // There's another bug in ob_gzhandler (see also the comment at
887 // the top of this function) that causes it to gzip even empty
888 // responses, meaning it's impossible to produce a truly empty
889 // response (because the gzip header is always there). This is
890 // a problem because 304 responses have to be completely empty
891 // per the HTTP spec, and Firefox behaves buggily when they're not.
892 // See also https://bugs.php.net/bug.php?id=51579
893 // To work around this, we tear down all output buffering before
894 // sending the 304.
895 wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
896
897 HttpStatus::header( 304 );
898
899 $this->sendResponseHeaders( $context, $etag, false );
900 return true;
901 }
902 return false;
903 }
904
905 /**
906 * Send out code for a response from file cache if possible.
907 *
908 * @param ResourceFileCache $fileCache Cache object for this request URL
909 * @param ResourceLoaderContext $context Context in which to generate a response
910 * @param string $etag ETag header value
911 * @return bool If this found a cache file and handled the response
912 */
913 protected function tryRespondFromFileCache(
914 ResourceFileCache $fileCache,
915 ResourceLoaderContext $context,
916 $etag
917 ) {
918 $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
919 // Buffer output to catch warnings.
920 ob_start();
921 // Get the maximum age the cache can be
922 $maxage = is_null( $context->getVersion() )
923 ? $rlMaxage['unversioned']['server']
924 : $rlMaxage['versioned']['server'];
925 // Minimum timestamp the cache file must have
926 $good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) );
927 if ( !$good ) {
928 try { // RL always hits the DB on file cache miss...
929 wfGetDB( DB_REPLICA );
930 } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
931 $good = $fileCache->isCacheGood(); // cache existence check
932 }
933 }
934 if ( $good ) {
935 $ts = $fileCache->cacheTimestamp();
936 // Send content type and cache headers
937 $this->sendResponseHeaders( $context, $etag, false );
938 $response = $fileCache->fetchText();
939 // Capture any PHP warnings from the output buffer and append them to the
940 // response in a comment if we're in debug mode.
941 if ( $context->getDebug() ) {
942 $warnings = ob_get_contents();
943 if ( strlen( $warnings ) ) {
944 $response = self::makeComment( $warnings ) . $response;
945 }
946 }
947 // Remove the output buffer and output the response
948 ob_end_clean();
949 echo $response . "\n/* Cached {$ts} */";
950 return true; // cache hit
951 }
952 // Clear buffer
953 ob_end_clean();
954
955 return false; // cache miss
956 }
957
958 /**
959 * Generate a CSS or JS comment block.
960 *
961 * Only use this for public data, not error message details.
962 *
963 * @param string $text
964 * @return string
965 */
966 public static function makeComment( $text ) {
967 $encText = str_replace( '*/', '* /', $text );
968 return "/*\n$encText\n*/\n";
969 }
970
971 /**
972 * Handle exception display.
973 *
974 * @param Exception $e Exception to be shown to the user
975 * @return string Sanitized text in a CSS/JS comment that can be returned to the user
976 */
977 public static function formatException( $e ) {
978 return self::makeComment( self::formatExceptionNoComment( $e ) );
979 }
980
981 /**
982 * Handle exception display.
983 *
984 * @since 1.25
985 * @param Exception $e Exception to be shown to the user
986 * @return string Sanitized text that can be returned to the user
987 */
988 protected static function formatExceptionNoComment( $e ) {
989 global $wgShowExceptionDetails;
990
991 if ( !$wgShowExceptionDetails ) {
992 return MWExceptionHandler::getPublicLogMessage( $e );
993 }
994
995 return MWExceptionHandler::getLogMessage( $e ) .
996 "\nBacktrace:\n" .
997 MWExceptionHandler::getRedactedTraceAsString( $e );
998 }
999
1000 /**
1001 * Generate code for a response.
1002 *
1003 * @param ResourceLoaderContext $context Context in which to generate a response
1004 * @param ResourceLoaderModule[] $modules List of module objects keyed by module name
1005 * @param string[] $missing List of requested module names that are unregistered (optional)
1006 * @return string Response data
1007 */
1008 public function makeModuleResponse( ResourceLoaderContext $context,
1009 array $modules, array $missing = []
1010 ) {
1011 $out = '';
1012 $states = [];
1013
1014 if ( !count( $modules ) && !count( $missing ) ) {
1015 return <<<MESSAGE
1016 /* This file is the Web entry point for MediaWiki's ResourceLoader:
1017 <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
1018 no modules were requested. Max made me put this here. */
1019 MESSAGE;
1020 }
1021
1022 $image = $context->getImageObj();
1023 if ( $image ) {
1024 $data = $image->getImageData( $context );
1025 if ( $data === false ) {
1026 $data = '';
1027 $this->errors[] = 'Image generation failed';
1028 }
1029 return $data;
1030 }
1031
1032 foreach ( $missing as $name ) {
1033 $states[$name] = 'missing';
1034 }
1035
1036 // Generate output
1037 $isRaw = false;
1038
1039 $filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js';
1040
1041 foreach ( $modules as $name => $module ) {
1042 try {
1043 $content = $module->getModuleContent( $context );
1044 $implementKey = $name . '@' . $module->getVersionHash( $context );
1045 $strContent = '';
1046
1047 // Append output
1048 switch ( $context->getOnly() ) {
1049 case 'scripts':
1050 $scripts = $content['scripts'];
1051 if ( is_string( $scripts ) ) {
1052 // Load scripts raw...
1053 $strContent = $scripts;
1054 } elseif ( is_array( $scripts ) ) {
1055 // ...except when $scripts is an array of URLs
1056 $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
1057 }
1058 break;
1059 case 'styles':
1060 $styles = $content['styles'];
1061 // We no longer seperate into media, they are all combined now with
1062 // custom media type groups into @media .. {} sections as part of the css string.
1063 // Module returns either an empty array or a numerical array with css strings.
1064 $strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
1065 break;
1066 default:
1067 $scripts = isset( $content['scripts'] ) ? $content['scripts'] : '';
1068 if ( is_string( $scripts ) ) {
1069 if ( $name === 'site' || $name === 'user' ) {
1070 // Legacy scripts that run in the global scope without a closure.
1071 // mw.loader.implement will use globalEval if scripts is a string.
1072 // Minify manually here, because general response minification is
1073 // not effective due it being a string literal, not a function.
1074 if ( !ResourceLoader::inDebugMode() ) {
1075 $scripts = self::filter( 'minify-js', $scripts ); // T107377
1076 }
1077 } else {
1078 $scripts = new XmlJsCode( $scripts );
1079 }
1080 }
1081 $strContent = self::makeLoaderImplementScript(
1082 $implementKey,
1083 $scripts,
1084 isset( $content['styles'] ) ? $content['styles'] : [],
1085 isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
1086 isset( $content['templates'] ) ? $content['templates'] : []
1087 );
1088 break;
1089 }
1090
1091 if ( !$context->getDebug() ) {
1092 $strContent = self::filter( $filter, $strContent );
1093 }
1094
1095 $out .= $strContent;
1096
1097 } catch ( Exception $e ) {
1098 $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
1099
1100 // Respond to client with error-state instead of module implementation
1101 $states[$name] = 'error';
1102 unset( $modules[$name] );
1103 }
1104 $isRaw |= $module->isRaw();
1105 }
1106
1107 // Update module states
1108 if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
1109 if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
1110 // Set the state of modules loaded as only scripts to ready as
1111 // they don't have an mw.loader.implement wrapper that sets the state
1112 foreach ( $modules as $name => $module ) {
1113 $states[$name] = 'ready';
1114 }
1115 }
1116
1117 // Set the state of modules we didn't respond to with mw.loader.implement
1118 if ( count( $states ) ) {
1119 $stateScript = self::makeLoaderStateScript( $states );
1120 if ( !$context->getDebug() ) {
1121 $stateScript = self::filter( 'minify-js', $stateScript );
1122 }
1123 $out .= $stateScript;
1124 }
1125 } else {
1126 if ( count( $states ) ) {
1127 $this->errors[] = 'Problematic modules: ' .
1128 FormatJson::encode( $states, ResourceLoader::inDebugMode() );
1129 }
1130 }
1131
1132 return $out;
1133 }
1134
1135 /**
1136 * Get names of modules that use a certain message.
1137 *
1138 * @param string $messageKey
1139 * @return array List of module names
1140 */
1141 public function getModulesByMessage( $messageKey ) {
1142 $moduleNames = [];
1143 foreach ( $this->getModuleNames() as $moduleName ) {
1144 $module = $this->getModule( $moduleName );
1145 if ( in_array( $messageKey, $module->getMessages() ) ) {
1146 $moduleNames[] = $moduleName;
1147 }
1148 }
1149 return $moduleNames;
1150 }
1151
1152 /* Static Methods */
1153
1154 /**
1155 * Return JS code that calls mw.loader.implement with given module properties.
1156 *
1157 * @param string $name Module name or implement key (format "`[name]@[version]`")
1158 * @param XmlJsCode|array|string $scripts Code as XmlJsCode (to be wrapped in a closure),
1159 * list of URLs to JavaScript files, or a string of JavaScript for `$.globalEval`.
1160 * @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs
1161 * to CSS files keyed by media type
1162 * @param mixed $messages List of messages associated with this module. May either be an
1163 * associative array mapping message key to value, or a JSON-encoded message blob containing
1164 * the same data, wrapped in an XmlJsCode object.
1165 * @param array $templates Keys are name of templates and values are the source of
1166 * the template.
1167 * @throws MWException
1168 * @return string
1169 */
1170 protected static function makeLoaderImplementScript(
1171 $name, $scripts, $styles, $messages, $templates
1172 ) {
1173 if ( $scripts instanceof XmlJsCode ) {
1174 $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1175 } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1176 throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
1177 }
1178 // mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
1179 // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1180 // of "{}". Force them to objects.
1181 $module = [
1182 $name,
1183 $scripts,
1184 (object)$styles,
1185 (object)$messages,
1186 (object)$templates,
1187 ];
1188 self::trimArray( $module );
1189
1190 return Xml::encodeJsCall( 'mw.loader.implement', $module, ResourceLoader::inDebugMode() );
1191 }
1192
1193 /**
1194 * Returns JS code which, when called, will register a given list of messages.
1195 *
1196 * @param mixed $messages Either an associative array mapping message key to value, or a
1197 * JSON-encoded message blob containing the same data, wrapped in an XmlJsCode object.
1198 * @return string
1199 */
1200 public static function makeMessageSetScript( $messages ) {
1201 return Xml::encodeJsCall(
1202 'mw.messages.set',
1203 [ (object)$messages ],
1204 ResourceLoader::inDebugMode()
1205 );
1206 }
1207
1208 /**
1209 * Combines an associative array mapping media type to CSS into a
1210 * single stylesheet with "@media" blocks.
1211 *
1212 * @param array $stylePairs Array keyed by media type containing (arrays of) CSS strings
1213 * @return array
1214 */
1215 public static function makeCombinedStyles( array $stylePairs ) {
1216 $out = [];
1217 foreach ( $stylePairs as $media => $styles ) {
1218 // ResourceLoaderFileModule::getStyle can return the styles
1219 // as a string or an array of strings. This is to allow separation in
1220 // the front-end.
1221 $styles = (array)$styles;
1222 foreach ( $styles as $style ) {
1223 $style = trim( $style );
1224 // Don't output an empty "@media print { }" block (T42498)
1225 if ( $style !== '' ) {
1226 // Transform the media type based on request params and config
1227 // The way that this relies on $wgRequest to propagate request params is slightly evil
1228 $media = OutputPage::transformCssMedia( $media );
1229
1230 if ( $media === '' || $media == 'all' ) {
1231 $out[] = $style;
1232 } elseif ( is_string( $media ) ) {
1233 $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1234 }
1235 // else: skip
1236 }
1237 }
1238 }
1239 return $out;
1240 }
1241
1242 /**
1243 * Returns a JS call to mw.loader.state, which sets the state of a
1244 * module or modules to a given value. Has two calling conventions:
1245 *
1246 * - ResourceLoader::makeLoaderStateScript( $name, $state ):
1247 * Set the state of a single module called $name to $state
1248 *
1249 * - ResourceLoader::makeLoaderStateScript( [ $name => $state, ... ] ):
1250 * Set the state of modules with the given names to the given states
1251 *
1252 * @param string $name
1253 * @param string $state
1254 * @return string
1255 */
1256 public static function makeLoaderStateScript( $name, $state = null ) {
1257 if ( is_array( $name ) ) {
1258 return Xml::encodeJsCall(
1259 'mw.loader.state',
1260 [ $name ],
1261 ResourceLoader::inDebugMode()
1262 );
1263 } else {
1264 return Xml::encodeJsCall(
1265 'mw.loader.state',
1266 [ $name, $state ],
1267 ResourceLoader::inDebugMode()
1268 );
1269 }
1270 }
1271
1272 /**
1273 * Returns JS code which calls the script given by $script. The script will
1274 * be called with local variables name, version, dependencies and group,
1275 * which will have values corresponding to $name, $version, $dependencies
1276 * and $group as supplied.
1277 *
1278 * @param string $name Module name
1279 * @param string $version Module version hash
1280 * @param array $dependencies List of module names on which this module depends
1281 * @param string $group Group which the module is in.
1282 * @param string $source Source of the module, or 'local' if not foreign.
1283 * @param string $script JavaScript code
1284 * @return string
1285 */
1286 public static function makeCustomLoaderScript( $name, $version, $dependencies,
1287 $group, $source, $script
1288 ) {
1289 $script = str_replace( "\n", "\n\t", trim( $script ) );
1290 return Xml::encodeJsCall(
1291 "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1292 [ $name, $version, $dependencies, $group, $source ],
1293 ResourceLoader::inDebugMode()
1294 );
1295 }
1296
1297 private static function isEmptyObject( stdClass $obj ) {
1298 foreach ( $obj as $key => $value ) {
1299 return false;
1300 }
1301 return true;
1302 }
1303
1304 /**
1305 * Remove empty values from the end of an array.
1306 *
1307 * Values considered empty:
1308 *
1309 * - null
1310 * - []
1311 * - new XmlJsCode( '{}' )
1312 * - new stdClass() // (object) []
1313 *
1314 * @param Array $array
1315 */
1316 private static function trimArray( array &$array ) {
1317 $i = count( $array );
1318 while ( $i-- ) {
1319 if ( $array[$i] === null
1320 || $array[$i] === []
1321 || ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
1322 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1323 ) {
1324 unset( $array[$i] );
1325 } else {
1326 break;
1327 }
1328 }
1329 }
1330
1331 /**
1332 * Returns JS code which calls mw.loader.register with the given
1333 * parameters. Has three calling conventions:
1334 *
1335 * - ResourceLoader::makeLoaderRegisterScript( $name, $version,
1336 * $dependencies, $group, $source, $skip
1337 * ):
1338 * Register a single module.
1339 *
1340 * - ResourceLoader::makeLoaderRegisterScript( [ $name1, $name2 ] ):
1341 * Register modules with the given names.
1342 *
1343 * - ResourceLoader::makeLoaderRegisterScript( [
1344 * [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],
1345 * [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],
1346 * ...
1347 * ] ):
1348 * Registers modules with the given names and parameters.
1349 *
1350 * @param string $name Module name
1351 * @param string $version Module version hash
1352 * @param array $dependencies List of module names on which this module depends
1353 * @param string $group Group which the module is in
1354 * @param string $source Source of the module, or 'local' if not foreign
1355 * @param string $skip Script body of the skip function
1356 * @return string
1357 */
1358 public static function makeLoaderRegisterScript( $name, $version = null,
1359 $dependencies = null, $group = null, $source = null, $skip = null
1360 ) {
1361 if ( is_array( $name ) ) {
1362 // Build module name index
1363 $index = [];
1364 foreach ( $name as $i => &$module ) {
1365 $index[$module[0]] = $i;
1366 }
1367
1368 // Transform dependency names into indexes when possible, they will be resolved by
1369 // mw.loader.register on the other end
1370 foreach ( $name as &$module ) {
1371 if ( isset( $module[2] ) ) {
1372 foreach ( $module[2] as &$dependency ) {
1373 if ( isset( $index[$dependency] ) ) {
1374 $dependency = $index[$dependency];
1375 }
1376 }
1377 }
1378 }
1379
1380 array_walk( $name, [ 'self', 'trimArray' ] );
1381
1382 return Xml::encodeJsCall(
1383 'mw.loader.register',
1384 [ $name ],
1385 ResourceLoader::inDebugMode()
1386 );
1387 } else {
1388 $registration = [ $name, $version, $dependencies, $group, $source, $skip ];
1389 self::trimArray( $registration );
1390 return Xml::encodeJsCall(
1391 'mw.loader.register',
1392 $registration,
1393 ResourceLoader::inDebugMode()
1394 );
1395 }
1396 }
1397
1398 /**
1399 * Returns JS code which calls mw.loader.addSource() with the given
1400 * parameters. Has two calling conventions:
1401 *
1402 * - ResourceLoader::makeLoaderSourcesScript( $id, $properties ):
1403 * Register a single source
1404 *
1405 * - ResourceLoader::makeLoaderSourcesScript( [ $id1 => $loadUrl, $id2 => $loadUrl, ... ] );
1406 * Register sources with the given IDs and properties.
1407 *
1408 * @param string $id Source ID
1409 * @param string $loadUrl load.php url
1410 * @return string
1411 */
1412 public static function makeLoaderSourcesScript( $id, $loadUrl = null ) {
1413 if ( is_array( $id ) ) {
1414 return Xml::encodeJsCall(
1415 'mw.loader.addSource',
1416 [ $id ],
1417 ResourceLoader::inDebugMode()
1418 );
1419 } else {
1420 return Xml::encodeJsCall(
1421 'mw.loader.addSource',
1422 [ $id, $loadUrl ],
1423 ResourceLoader::inDebugMode()
1424 );
1425 }
1426 }
1427
1428 /**
1429 * Returns JS code which runs given JS code if the client-side framework is
1430 * present.
1431 *
1432 * @deprecated since 1.25; use makeInlineScript instead
1433 * @param string $script JavaScript code
1434 * @return string
1435 */
1436 public static function makeLoaderConditionalScript( $script ) {
1437 return '(window.RLQ=window.RLQ||[]).push(function(){' .
1438 trim( $script ) . '});';
1439 }
1440
1441 /**
1442 * Construct an inline script tag with given JS code.
1443 *
1444 * The code will be wrapped in a closure, and it will be executed by ResourceLoader
1445 * only if the client has adequate support for MediaWiki JavaScript code.
1446 *
1447 * @param string $script JavaScript code
1448 * @return WrappedString HTML
1449 */
1450 public static function makeInlineScript( $script ) {
1451 $js = self::makeLoaderConditionalScript( $script );
1452 return new WrappedString(
1453 Html::inlineScript( $js ),
1454 '<script>(window.RLQ=window.RLQ||[]).push(function(){',
1455 '});</script>'
1456 );
1457 }
1458
1459 /**
1460 * Returns JS code which will set the MediaWiki configuration array to
1461 * the given value.
1462 *
1463 * @param array $configuration List of configuration values keyed by variable name
1464 * @return string
1465 */
1466 public static function makeConfigSetScript( array $configuration ) {
1467 return Xml::encodeJsCall(
1468 'mw.config.set',
1469 [ $configuration ],
1470 ResourceLoader::inDebugMode()
1471 );
1472 }
1473
1474 /**
1475 * Convert an array of module names to a packed query string.
1476 *
1477 * For example, [ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]
1478 * becomes 'foo.bar,baz|bar.baz,quux'
1479 * @param array $modules List of module names (strings)
1480 * @return string Packed query string
1481 */
1482 public static function makePackedModulesString( $modules ) {
1483 $groups = []; // [ prefix => [ suffixes ] ]
1484 foreach ( $modules as $module ) {
1485 $pos = strrpos( $module, '.' );
1486 $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1487 $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1488 $groups[$prefix][] = $suffix;
1489 }
1490
1491 $arr = [];
1492 foreach ( $groups as $prefix => $suffixes ) {
1493 $p = $prefix === '' ? '' : $prefix . '.';
1494 $arr[] = $p . implode( ',', $suffixes );
1495 }
1496 $str = implode( '|', $arr );
1497 return $str;
1498 }
1499
1500 /**
1501 * Determine whether debug mode was requested
1502 * Order of priority is 1) request param, 2) cookie, 3) $wg setting
1503 * @return bool
1504 */
1505 public static function inDebugMode() {
1506 if ( self::$debugMode === null ) {
1507 global $wgRequest, $wgResourceLoaderDebug;
1508 self::$debugMode = $wgRequest->getFuzzyBool( 'debug',
1509 $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug )
1510 );
1511 }
1512 return self::$debugMode;
1513 }
1514
1515 /**
1516 * Reset static members used for caching.
1517 *
1518 * Global state and $wgRequest are evil, but we're using it right
1519 * now and sometimes we need to be able to force ResourceLoader to
1520 * re-evaluate the context because it has changed (e.g. in the test suite).
1521 */
1522 public static function clearCache() {
1523 self::$debugMode = null;
1524 }
1525
1526 /**
1527 * Build a load.php URL
1528 *
1529 * @since 1.24
1530 * @param string $source Name of the ResourceLoader source
1531 * @param ResourceLoaderContext $context
1532 * @param array $extraQuery
1533 * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
1534 */
1535 public function createLoaderURL( $source, ResourceLoaderContext $context,
1536 $extraQuery = []
1537 ) {
1538 $query = self::createLoaderQuery( $context, $extraQuery );
1539 $script = $this->getLoadScript( $source );
1540
1541 return wfAppendQuery( $script, $query );
1542 }
1543
1544 /**
1545 * Helper for createLoaderURL()
1546 *
1547 * @since 1.24
1548 * @see makeLoaderQuery
1549 * @param ResourceLoaderContext $context
1550 * @param array $extraQuery
1551 * @return array
1552 */
1553 protected static function createLoaderQuery( ResourceLoaderContext $context, $extraQuery = [] ) {
1554 return self::makeLoaderQuery(
1555 $context->getModules(),
1556 $context->getLanguage(),
1557 $context->getSkin(),
1558 $context->getUser(),
1559 $context->getVersion(),
1560 $context->getDebug(),
1561 $context->getOnly(),
1562 $context->getRequest()->getBool( 'printable' ),
1563 $context->getRequest()->getBool( 'handheld' ),
1564 $extraQuery
1565 );
1566 }
1567
1568 /**
1569 * Build a query array (array representation of query string) for load.php. Helper
1570 * function for createLoaderURL().
1571 *
1572 * @param array $modules
1573 * @param string $lang
1574 * @param string $skin
1575 * @param string $user
1576 * @param string $version
1577 * @param bool $debug
1578 * @param string $only
1579 * @param bool $printable
1580 * @param bool $handheld
1581 * @param array $extraQuery
1582 *
1583 * @return array
1584 */
1585 public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
1586 $version = null, $debug = false, $only = null, $printable = false,
1587 $handheld = false, $extraQuery = []
1588 ) {
1589 $query = [
1590 'modules' => self::makePackedModulesString( $modules ),
1591 'lang' => $lang,
1592 'skin' => $skin,
1593 'debug' => $debug ? 'true' : 'false',
1594 ];
1595 if ( $user !== null ) {
1596 $query['user'] = $user;
1597 }
1598 if ( $version !== null ) {
1599 $query['version'] = $version;
1600 }
1601 if ( $only !== null ) {
1602 $query['only'] = $only;
1603 }
1604 if ( $printable ) {
1605 $query['printable'] = 1;
1606 }
1607 if ( $handheld ) {
1608 $query['handheld'] = 1;
1609 }
1610 $query += $extraQuery;
1611
1612 // Make queries uniform in order
1613 ksort( $query );
1614 return $query;
1615 }
1616
1617 /**
1618 * Check a module name for validity.
1619 *
1620 * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
1621 * at most 255 bytes.
1622 *
1623 * @param string $moduleName Module name to check
1624 * @return bool Whether $moduleName is a valid module name
1625 */
1626 public static function isValidModuleName( $moduleName ) {
1627 return strcspn( $moduleName, '!,|', 0, 255 ) === strlen( $moduleName );
1628 }
1629
1630 /**
1631 * Returns LESS compiler set up for use with MediaWiki
1632 *
1633 * @since 1.27
1634 * @param array $extraVars Associative array of extra (i.e., other than the
1635 * globally-configured ones) that should be used for compilation.
1636 * @throws MWException
1637 * @return Less_Parser
1638 */
1639 public function getLessCompiler( $extraVars = [] ) {
1640 // When called from the installer, it is possible that a required PHP extension
1641 // is missing (at least for now; see T49564). If this is the case, throw an
1642 // exception (caught by the installer) to prevent a fatal error later on.
1643 if ( !class_exists( 'Less_Parser' ) ) {
1644 throw new MWException( 'MediaWiki requires the less.php parser' );
1645 }
1646
1647 $parser = new Less_Parser;
1648 $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1649 $parser->SetImportDirs(
1650 array_fill_keys( $this->config->get( 'ResourceLoaderLESSImportPaths' ), '' )
1651 );
1652 $parser->SetOption( 'relativeUrls', false );
1653
1654 return $parser;
1655 }
1656
1657 /**
1658 * Get global LESS variables.
1659 *
1660 * @since 1.27
1661 * @return array Map of variable names to string CSS values.
1662 */
1663 public function getLessVars() {
1664 if ( !$this->lessVars ) {
1665 $lessVars = $this->config->get( 'ResourceLoaderLESSVars' );
1666 Hooks::run( 'ResourceLoaderGetLessVars', [ &$lessVars ] );
1667 $this->lessVars = $lessVars;
1668 }
1669 return $this->lessVars;
1670 }
1671 }