]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blob - includes/resourceloader/ResourceLoaderFileModule.php
MediaWiki 1.17.1
[autoinstallsdev/mediawiki.git] / includes / resourceloader / ResourceLoaderFileModule.php
1 <?php
2 /**
3  * This program is free software; you can redistribute it and/or modify
4  * it under the terms of the GNU General Public License as published by
5  * the Free Software Foundation; either version 2 of the License, or
6  * (at your option) any later version.
7  *
8  * This program is distributed in the hope that it will be useful,
9  * but WITHOUT ANY WARRANTY; without even the implied warranty of
10  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11  * GNU General Public License for more details.
12  *
13  * You should have received a copy of the GNU General Public License along
14  * with this program; if not, write to the Free Software Foundation, Inc.,
15  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16  * http://www.gnu.org/copyleft/gpl.html
17  *
18  * @file
19  * @author Trevor Parscal
20  * @author Roan Kattouw
21  */
22
23 /**
24  * ResourceLoader module based on local JavaScript/CSS files.
25  */
26 class ResourceLoaderFileModule extends ResourceLoaderModule {
27
28         /* Protected Members */
29
30         /** String: Local base path, see __construct() */
31         protected $localBasePath = '';
32         /** String: Remote base path, see __construct() */
33         protected $remoteBasePath = '';
34         /**
35          * Array: List of paths to JavaScript files to always include
36          * @example array( [file-path], [file-path], ... )
37          */
38         protected $scripts = array();
39         /**
40          * Array: List of JavaScript files to include when using a specific language
41          * @example array( [language-code] => array( [file-path], [file-path], ... ), ... )
42          */
43         protected $languageScripts = array();
44         /**
45          * Array: List of JavaScript files to include when using a specific skin
46          * @example array( [skin-name] => array( [file-path], [file-path], ... ), ... )
47          */
48         protected $skinScripts = array();
49         /**
50          * Array: List of paths to JavaScript files to include in debug mode
51          * @example array( [skin-name] => array( [file-path], [file-path], ... ), ... )
52          */
53         protected $debugScripts = array();
54         /**
55          * Array: List of paths to JavaScript files to include in the startup module
56          * @example array( [file-path], [file-path], ... )
57          */
58         protected $loaderScripts = array();
59         /**
60          * Array: List of paths to CSS files to always include
61          * @example array( [file-path], [file-path], ... )
62          */
63         protected $styles = array();
64         /**
65          * Array: List of paths to CSS files to include when using specific skins
66          * @example array( [file-path], [file-path], ... )
67          */
68         protected $skinStyles = array();
69         /**
70          * Array: List of modules this module depends on
71          * @example array( [file-path], [file-path], ... )
72          */
73         protected $dependencies = array();
74         /**
75          * Array: List of message keys used by this module
76          * @example array( [message-key], [message-key], ... )
77          */
78         protected $messages = array();
79         /** String: Name of group to load this module in */
80         protected $group;
81         /** Boolean: Link to raw files in debug mode */
82         protected $debugRaw = true;
83         /**
84          * Array: Cache for mtime
85          * @example array( [hash] => [mtime], [hash] => [mtime], ... )
86          */
87         protected $modifiedTime = array();
88         /**
89          * Array: Place where readStyleFile() tracks file dependencies
90          * @example array( [file-path], [file-path], ... )
91          */
92         protected $localFileRefs = array();
93
94         /* Methods */
95
96         /**
97          * Constructs a new module from an options array.
98          * 
99          * @param $options Array: List of options; if not given or empty, an empty module will be
100          *     constructed
101          * @param $localBasePath String: Base path to prepend to all local paths in $options. Defaults
102          *     to $IP
103          * @param $remoteBasePath String: Base path to prepend to all remote paths in $options. Defaults
104          *     to $wgScriptPath
105          * 
106          * @example $options
107          *      array(
108          *              // Base path to prepend to all local paths in $options. Defaults to $IP
109          *              'localBasePath' => [base path],
110          *              // Base path to prepend to all remote paths in $options. Defaults to $wgScriptPath
111          *              'remoteBasePath' => [base path],
112          *              // Equivalent of remoteBasePath, but relative to $wgExtensionAssetsPath
113          *              'remoteExtPath' => [base path],
114          *              // Scripts to always include
115          *              'scripts' => [file path string or array of file path strings],
116          *              // Scripts to include in specific language contexts
117          *              'languageScripts' => array(
118          *                      [language code] => [file path string or array of file path strings],
119          *              ),
120          *              // Scripts to include in specific skin contexts
121          *              'skinScripts' => array(
122          *                      [skin name] => [file path string or array of file path strings],
123          *              ),
124          *              // Scripts to include in debug contexts
125          *              'debugScripts' => [file path string or array of file path strings],
126          *              // Scripts to include in the startup module
127          *              'loaderScripts' => [file path string or array of file path strings],
128          *              // Modules which must be loaded before this module
129          *              'dependencies' => [modile name string or array of module name strings],
130          *              // Styles to always load
131          *              'styles' => [file path string or array of file path strings],
132          *              // Styles to include in specific skin contexts
133          *              'skinStyles' => array(
134          *                      [skin name] => [file path string or array of file path strings],
135          *              ),
136          *              // Messages to always load
137          *              'messages' => [array of message key strings],
138          *              // Group which this module should be loaded together with
139          *              'group' => [group name string],
140          *      )
141          */
142         public function __construct( $options = array(), $localBasePath = null, 
143                 $remoteBasePath = null ) 
144         {
145                 global $IP, $wgScriptPath;
146                 $this->localBasePath = $localBasePath === null ? $IP : $localBasePath;
147                 $this->remoteBasePath = $remoteBasePath === null ? $wgScriptPath : $remoteBasePath;
148
149                 if ( isset( $options['remoteExtPath'] ) ) {
150                         global $wgExtensionAssetsPath;
151                         $this->remoteBasePath = $wgExtensionAssetsPath . '/' . $options['remoteExtPath'];
152                 }
153
154                 foreach ( $options as $member => $option ) {
155                         switch ( $member ) {
156                                 // Lists of file paths
157                                 case 'scripts':
158                                 case 'debugScripts':
159                                 case 'loaderScripts':
160                                 case 'styles':
161                                         $this->{$member} = (array) $option;
162                                         break;
163                                 // Collated lists of file paths
164                                 case 'languageScripts':
165                                 case 'skinScripts':
166                                 case 'skinStyles':
167                                         if ( !is_array( $option ) ) {
168                                                 throw new MWException(
169                                                         "Invalid collated file path list error. " . 
170                                                         "'$option' given, array expected."
171                                                 );
172                                         }
173                                         foreach ( $option as $key => $value ) {
174                                                 if ( !is_string( $key ) ) {
175                                                         throw new MWException(
176                                                                 "Invalid collated file path list key error. " . 
177                                                                 "'$key' given, string expected."
178                                                         );
179                                                 }
180                                                 $this->{$member}[$key] = (array) $value;
181                                         }
182                                         break;
183                                 // Lists of strings
184                                 case 'dependencies':
185                                 case 'messages':
186                                         $this->{$member} = (array) $option;
187                                         break;
188                                 // Single strings
189                                 case 'group':
190                                 case 'localBasePath':
191                                 case 'remoteBasePath':
192                                         $this->{$member} = (string) $option;
193                                         break;
194                                 // Single booleans
195                                 case 'debugRaw':
196                                         $this->{$member} = (bool) $option;
197                                         break;
198                         }
199                 }
200                 // Make sure the remote base path is a complete valid url
201                 $this->remoteBasePath = wfExpandUrl( $this->remoteBasePath );
202         }
203
204         /**
205          * Gets all scripts for a given context concatenated together.
206          * 
207          * @param $context ResourceLoaderContext: Context in which to generate script
208          * @return String: JavaScript code for $context
209          */
210         public function getScript( ResourceLoaderContext $context ) {
211                 $files = array_merge(
212                         $this->scripts,
213                         self::tryForKey( $this->languageScripts, $context->getLanguage() ),
214                         self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
215                 );
216                 if ( $context->getDebug() ) {
217                         $files = array_merge( $files, $this->debugScripts );
218                         if ( $this->debugRaw ) {
219                                 $script = '';
220                                 foreach ( $files as $file ) {
221                                         $path = $this->getRemotePath( $file );
222                                         $script .= "\n\t" . Xml::encodeJsCall( 'mediaWiki.loader.load', array( $path ) );
223                                 }
224                                 return $script;
225                         }
226                 }
227                 return $this->readScriptFiles( $files );
228         }
229
230         /**
231          * Gets loader script.
232          * 
233          * @return String: JavaScript code to be added to startup module
234          */
235         public function getLoaderScript() {
236                 if ( count( $this->loaderScripts ) == 0 ) {
237                         return false;
238                 }
239                 return $this->readScriptFiles( $this->loaderScripts );
240         }
241
242         /**
243          * Gets all styles for a given context concatenated together.
244          * 
245          * @param $context ResourceLoaderContext: Context in which to generate styles
246          * @return String: CSS code for $context
247          */
248         public function getStyles( ResourceLoaderContext $context ) {
249                 // Merge general styles and skin specific styles, retaining media type collation
250                 $styles = $this->readStyleFiles( $this->styles, $this->getFlip( $context ) );
251                 $skinStyles = $this->readStyleFiles( 
252                         self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' ),
253                         $this->getFlip( $context )
254                 );
255                 
256                 foreach ( $skinStyles as $media => $style ) {
257                         if ( isset( $styles[$media] ) ) {
258                                 $styles[$media] .= $style;
259                         } else {
260                                 $styles[$media] = $style;
261                         }
262                 }
263                 // Collect referenced files
264                 $this->localFileRefs = array_unique( $this->localFileRefs );
265                 // If the list has been modified since last time we cached it, update the cache
266                 if ( $this->localFileRefs !== $this->getFileDependencies( $context->getSkin() ) ) {
267                         $dbw = wfGetDB( DB_MASTER );
268                         $dbw->replace( 'module_deps',
269                                 array( array( 'md_module', 'md_skin' ) ), array(
270                                         'md_module' => $this->getName(),
271                                         'md_skin' => $context->getSkin(),
272                                         'md_deps' => FormatJson::encode( $this->localFileRefs ),
273                                 )
274                         );
275                 }
276                 return $styles;
277         }
278
279         /**
280          * Gets list of message keys used by this module.
281          * 
282          * @return Array: List of message keys
283          */
284         public function getMessages() {
285                 return $this->messages;
286         }
287
288         /**
289          * Gets the name of the group this module should be loaded in.
290          * 
291          * @return String: Group name
292          */
293         public function getGroup() {
294                 return $this->group;
295         }
296
297         /**
298          * Gets list of names of modules this module depends on.
299          * 
300          * @return Array: List of module names
301          */
302         public function getDependencies() {
303                 return $this->dependencies;
304         }
305
306         /**
307          * Get the last modified timestamp of this module.
308          * 
309          * Last modified timestamps are calculated from the highest last modified 
310          * timestamp of this module's constituent files as well as the files it 
311          * depends on. This function is context-sensitive, only performing 
312          * calculations on files relevant to the given language, skin and debug 
313          * mode.
314          * 
315          * @param $context ResourceLoaderContext: Context in which to calculate 
316          *     the modified time
317          * @return Integer: UNIX timestamp
318          * @see ResourceLoaderModule::getFileDependencies
319          */
320         public function getModifiedTime( ResourceLoaderContext $context ) {
321                 if ( isset( $this->modifiedTime[$context->getHash()] ) ) {
322                         return $this->modifiedTime[$context->getHash()];
323                 }
324                 wfProfileIn( __METHOD__ );
325                 
326                 $files = array();
327                 
328                 // Flatten style files into $files
329                 $styles = self::collateFilePathListByOption( $this->styles, 'media', 'all' );
330                 foreach ( $styles as $styleFiles ) {
331                         $files = array_merge( $files, $styleFiles );
332                 }
333                 $skinFiles = self::tryForKey(
334                         self::collateFilePathListByOption( $this->skinStyles, 'media', 'all' ), 
335                         $context->getSkin(), 
336                         'default'
337                 );
338                 foreach ( $skinFiles as $styleFiles ) {
339                         $files = array_merge( $files, $styleFiles );
340                 }
341                 
342                 // Final merge, this should result in a master list of dependent files
343                 $files = array_merge(
344                         $files,
345                         $this->scripts,
346                         $context->getDebug() ? $this->debugScripts : array(),
347                         self::tryForKey( $this->languageScripts, $context->getLanguage() ),
348                         self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
349                         $this->loaderScripts
350                 );
351                 $files = array_map( array( $this, 'getLocalPath' ), $files );
352                 // File deps need to be treated separately because they're already prefixed
353                 $files = array_merge( $files, $this->getFileDependencies( $context->getSkin() ) );
354                 
355                 // If a module is nothing but a list of dependencies, we need to avoid 
356                 // giving max() an empty array
357                 if ( count( $files ) === 0 ) {
358                         wfProfileOut( __METHOD__ );
359                         return $this->modifiedTime[$context->getHash()] = 1;
360                 }
361                 
362                 wfProfileIn( __METHOD__.'-filemtime' );
363                 $filesMtime = max( array_map( array( __CLASS__, 'safeFilemtime' ), $files ) );
364                 wfProfileOut( __METHOD__.'-filemtime' );
365                 $this->modifiedTime[$context->getHash()] = max( 
366                         $filesMtime, 
367                         $this->getMsgBlobMtime( $context->getLanguage() ) );
368
369                 wfProfileOut( __METHOD__ );
370                 return $this->modifiedTime[$context->getHash()];
371         }
372
373         /* Protected Members */
374
375         protected function getLocalPath( $path ) {
376                 return "{$this->localBasePath}/$path";
377         }
378         
379         protected function getRemotePath( $path ) {
380                 return "{$this->remoteBasePath}/$path";
381         }
382
383         /**
384          * Collates file paths by option (where provided).
385          * 
386          * @param $list Array: List of file paths in any combination of index/path 
387          *     or path/options pairs
388          * @param $option String: option name
389          * @param $default Mixed: default value if the option isn't set
390          * @return Array: List of file paths, collated by $option
391          */
392         protected static function collateFilePathListByOption( array $list, $option, $default ) {
393                 $collatedFiles = array();
394                 foreach ( (array) $list as $key => $value ) {
395                         if ( is_int( $key ) ) {
396                                 // File name as the value
397                                 if ( !isset( $collatedFiles[$default] ) ) {
398                                         $collatedFiles[$default] = array();
399                                 }
400                                 $collatedFiles[$default][] = $value;
401                         } else if ( is_array( $value ) ) {
402                                 // File name as the key, options array as the value
403                                 $optionValue = isset( $value[$option] ) ? $value[$option] : $default;
404                                 if ( !isset( $collatedFiles[$optionValue] ) ) {
405                                         $collatedFiles[$optionValue] = array();
406                                 }
407                                 $collatedFiles[$optionValue][] = $key;
408                         }
409                 }
410                 return $collatedFiles;
411         }
412
413         /**
414          * Gets a list of element that match a key, optionally using a fallback key.
415          * 
416          * @param $list Array: List of lists to select from
417          * @param $key String: Key to look for in $map
418          * @param $fallback String: Key to look for in $list if $key doesn't exist
419          * @return Array: List of elements from $map which matched $key or $fallback, 
420          *     or an empty list in case of no match
421          */
422         protected static function tryForKey( array $list, $key, $fallback = null ) {
423                 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
424                         return $list[$key];
425                 } else if ( is_string( $fallback ) 
426                         && isset( $list[$fallback] ) 
427                         && is_array( $list[$fallback] ) ) 
428                 {
429                         return $list[$fallback];
430                 }
431                 return array();
432         }
433
434         /**
435          * Gets the contents of a list of JavaScript files.
436          * 
437          * @param $scripts Array: List of file paths to scripts to read, remap and concetenate
438          * @return String: Concatenated and remapped JavaScript data from $scripts
439          */
440         protected function readScriptFiles( array $scripts ) {
441                 if ( empty( $scripts ) ) {
442                         return '';
443                 }
444                 global $wgResourceLoaderValidateStaticJS;
445                 $js = '';
446                 foreach ( array_unique( $scripts ) as $fileName ) {
447                         $localPath = $this->getLocalPath( $fileName );
448                         if ( !file_exists( $localPath ) ) {
449                                 throw new MWException( __METHOD__.": script file not found: \"$localPath\"" );
450                         }
451                         $contents = file_get_contents( $localPath );
452                         if ( $wgResourceLoaderValidateStaticJS ) {
453                                 // Static files don't really need to be checked as often; unlike
454                                 // on-wiki module they shouldn't change unexpectedly without
455                                 // admin interference.
456                                 $contents = $this->validateScriptFile( $fileName, $contents );
457                         }
458                         $js .= $contents . "\n";
459                 }
460                 return $js;
461         }
462
463         /**
464          * Gets the contents of a list of CSS files.
465          * 
466          * @param $styles Array: List of file paths to styles to read, remap and concetenate
467          * @return Array: List of concatenated and remapped CSS data from $styles, 
468          *     keyed by media type
469          */
470         protected function readStyleFiles( array $styles, $flip ) {
471                 if ( empty( $styles ) ) {
472                         return array();
473                 }
474                 $styles = self::collateFilePathListByOption( $styles, 'media', 'all' );
475                 foreach ( $styles as $media => $files ) {
476                         $uniqueFiles = array_unique( $files );
477                         $styles[$media] = implode(
478                                 "\n",
479                                 array_map(
480                                         array( $this, 'readStyleFile' ),
481                                         $uniqueFiles,
482                                         array_fill( 0, count( $uniqueFiles ), $flip )
483                                 )
484                         );
485                 }
486                 return $styles;
487         }
488
489         /**
490          * Reads a style file.
491          * 
492          * This method can be used as a callback for array_map()
493          * 
494          * @param $path String: File path of style file to read
495          * @return String: CSS data in script file
496          * @throws MWException if the file doesn't exist
497          */
498         protected function readStyleFile( $path, $flip ) {      
499                 $localPath = $this->getLocalPath( $path );
500                 if ( !file_exists( $localPath ) ) {
501                         throw new MWException( __METHOD__.": style file not found: \"$localPath\"" );
502                 }
503                 $style = file_get_contents( $localPath );
504                 if ( $flip ) {
505                         $style = CSSJanus::transform( $style, true, false );
506                 }
507                 $dir = $this->getLocalPath( dirname( $path ) );
508                 $remoteDir = $this->getRemotePath( dirname( $path ) );
509                 // Get and register local file references
510                 $this->localFileRefs = array_merge( 
511                         $this->localFileRefs, 
512                         CSSMin::getLocalFileReferences( $style, $dir ) );
513                 return CSSMin::remap(
514                         $style, $dir, $remoteDir, true
515                 );
516         }
517         
518         /**
519          * Safe version of filemtime(), which doesn't throw a PHP warning if the file doesn't exist
520          * but returns 1 instead.
521          * @param $filename string File name
522          * @return int UNIX timestamp, or 1 if the file doesn't exist
523          */
524         protected static function safeFilemtime( $filename ) {
525                 if ( file_exists( $filename ) ) {
526                         return filemtime( $filename );
527                 } else {
528                         // We only ever map this function on an array if we're gonna call max() after,
529                         // so return our standard minimum timestamps here. This is 1, not 0, because
530                         // wfTimestamp(0) == NOW
531                         return 1;
532                 }
533         }
534
535         /**
536          * Get whether CSS for this module should be flipped
537          * @param $context ResourceLoaderContext
538          * @return bool
539          */
540         public function getFlip( $context ) {
541                 return $context->getDirection() === 'rtl';
542         }
543 }