Part 2 of 2, moved ResourceLoader*Module classes to their own files - this commit...
[lhc/web/wiklou.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 defined( 'MEDIAWIKI' ) || die( 1 );
24
25 /**
26 * Module based on local JS/CSS files. This is the most common type of module.
27 */
28 class ResourceLoaderFileModule extends ResourceLoaderModule {
29 /* Protected Members */
30
31 protected $scripts = array();
32 protected $styles = array();
33 protected $messages = array();
34 protected $group;
35 protected $dependencies = array();
36 protected $debugScripts = array();
37 protected $languageScripts = array();
38 protected $skinScripts = array();
39 protected $skinStyles = array();
40 protected $loaders = array();
41 protected $parameters = array();
42
43 // In-object cache for file dependencies
44 protected $fileDeps = array();
45 // In-object cache for mtime
46 protected $modifiedTime = array();
47
48 /* Methods */
49
50 /**
51 * Construct a new module from an options array.
52 *
53 * @param $options array Options array. If empty, an empty module will be constructed
54 *
55 * $options format:
56 * array(
57 * // Required module options (mutually exclusive)
58 * 'scripts' => 'dir/script.js' | array( 'dir/script1.js', 'dir/script2.js' ... ),
59 *
60 * // Optional module options
61 * 'languageScripts' => array(
62 * '[lang name]' => 'dir/lang.js' | '[lang name]' => array( 'dir/lang1.js', 'dir/lang2.js' ... )
63 * ...
64 * ),
65 * 'skinScripts' => 'dir/skin.js' | array( 'dir/skin1.js', 'dir/skin2.js' ... ),
66 * 'debugScripts' => 'dir/debug.js' | array( 'dir/debug1.js', 'dir/debug2.js' ... ),
67 *
68 * // Non-raw module options
69 * 'dependencies' => 'module' | array( 'module1', 'module2' ... )
70 * 'loaderScripts' => 'dir/loader.js' | array( 'dir/loader1.js', 'dir/loader2.js' ... ),
71 * 'styles' => 'dir/file.css' | array( 'dir/file1.css', 'dir/file2.css' ... ), |
72 * array( 'dir/file1.css' => array( 'media' => 'print' ) ),
73 * 'skinStyles' => array(
74 * '[skin name]' => 'dir/skin.css' | array( 'dir/skin1.css', 'dir/skin2.css' ... ) |
75 * array( 'dir/file1.css' => array( 'media' => 'print' )
76 * ...
77 * ),
78 * 'messages' => array( 'message1', 'message2' ... ),
79 * 'group' => 'stuff',
80 * )
81 *
82 * @param $basePath String: base path to prepend to all paths in $options
83 */
84 public function __construct( $options = array(), $basePath = null ) {
85 foreach ( $options as $option => $value ) {
86 switch ( $option ) {
87 case 'scripts':
88 case 'debugScripts':
89 case 'languageScripts':
90 case 'skinScripts':
91 case 'loaders':
92 $this->{$option} = (array)$value;
93 // Automatically prefix script paths
94 if ( is_string( $basePath ) ) {
95 foreach ( $this->{$option} as $key => $value ) {
96 $this->{$option}[$key] = $basePath . $value;
97 }
98 }
99 break;
100 case 'styles':
101 case 'skinStyles':
102 $this->{$option} = (array)$value;
103 // Automatically prefix style paths
104 if ( is_string( $basePath ) ) {
105 foreach ( $this->{$option} as $key => $value ) {
106 if ( is_array( $value ) ) {
107 $this->{$option}[$basePath . $key] = $value;
108 unset( $this->{$option}[$key] );
109 } else {
110 $this->{$option}[$key] = $basePath . $value;
111 }
112 }
113 }
114 break;
115 case 'dependencies':
116 case 'messages':
117 $this->{$option} = (array)$value;
118 break;
119 case 'group':
120 $this->group = (string)$value;
121 break;
122 }
123 }
124 }
125
126 /**
127 * Add script files to this module. In order to be valid, a module
128 * must contain at least one script file.
129 *
130 * @param $scripts Mixed: path to script file (string) or array of paths
131 */
132 public function addScripts( $scripts ) {
133 $this->scripts = array_merge( $this->scripts, (array)$scripts );
134 }
135
136 /**
137 * Add style (CSS) files to this module.
138 *
139 * @param $styles Mixed: path to CSS file (string) or array of paths
140 */
141 public function addStyles( $styles ) {
142 $this->styles = array_merge( $this->styles, (array)$styles );
143 }
144
145 /**
146 * Add messages to this module.
147 *
148 * @param $messages Mixed: message key (string) or array of message keys
149 */
150 public function addMessages( $messages ) {
151 $this->messages = array_merge( $this->messages, (array)$messages );
152 }
153
154 /**
155 * Sets the group of this module.
156 *
157 * @param $group string group name
158 */
159 public function setGroup( $group ) {
160 $this->group = $group;
161 }
162
163 /**
164 * Add dependencies. Dependency information is taken into account when
165 * loading a module on the client side. When adding a module on the
166 * server side, dependency information is NOT taken into account and
167 * YOU are responsible for adding dependent modules as well. If you
168 * don't do this, the client side loader will send a second request
169 * back to the server to fetch the missing modules, which kind of
170 * defeats the point of using the resource loader in the first place.
171 *
172 * To add dependencies dynamically on the client side, use a custom
173 * loader (see addLoaders())
174 *
175 * @param $dependencies Mixed: module name (string) or array of module names
176 */
177 public function addDependencies( $dependencies ) {
178 $this->dependencies = array_merge( $this->dependencies, (array)$dependencies );
179 }
180
181 /**
182 * Add debug scripts to the module. These scripts are only included
183 * in debug mode.
184 *
185 * @param $scripts Mixed: path to script file (string) or array of paths
186 */
187 public function addDebugScripts( $scripts ) {
188 $this->debugScripts = array_merge( $this->debugScripts, (array)$scripts );
189 }
190
191 /**
192 * Add language-specific scripts. These scripts are only included for
193 * a given language.
194 *
195 * @param $lang String: language code
196 * @param $scripts Mixed: path to script file (string) or array of paths
197 */
198 public function addLanguageScripts( $lang, $scripts ) {
199 $this->languageScripts = array_merge_recursive(
200 $this->languageScripts,
201 array( $lang => $scripts )
202 );
203 }
204
205 /**
206 * Add skin-specific scripts. These scripts are only included for
207 * a given skin.
208 *
209 * @param $skin String: skin name, or 'default'
210 * @param $scripts Mixed: path to script file (string) or array of paths
211 */
212 public function addSkinScripts( $skin, $scripts ) {
213 $this->skinScripts = array_merge_recursive(
214 $this->skinScripts,
215 array( $skin => $scripts )
216 );
217 }
218
219 /**
220 * Add skin-specific CSS. These CSS files are only included for a
221 * given skin. If there are no skin-specific CSS files for a skin,
222 * the files defined for 'default' will be used, if any.
223 *
224 * @param $skin String: skin name, or 'default'
225 * @param $scripts Mixed: path to CSS file (string) or array of paths
226 */
227 public function addSkinStyles( $skin, $scripts ) {
228 $this->skinStyles = array_merge_recursive(
229 $this->skinStyles,
230 array( $skin => $scripts )
231 );
232 }
233
234 /**
235 * Add loader scripts. These scripts are loaded on every page and are
236 * responsible for registering this module using
237 * mediaWiki.loader.register(). If there are no loader scripts defined,
238 * the resource loader will register the module itself.
239 *
240 * Loader scripts are used to determine a module's dependencies
241 * dynamically on the client side (e.g. based on browser type/version).
242 * Note that loader scripts are included on every page, so they should
243 * be lightweight and use mediaWiki.loader.register()'s callback
244 * feature to defer dependency calculation.
245 *
246 * @param $scripts Mixed: path to script file (string) or array of paths
247 */
248 public function addLoaders( $scripts ) {
249 $this->loaders = array_merge( $this->loaders, (array)$scripts );
250 }
251
252 public function getScript( ResourceLoaderContext $context ) {
253 $retval = $this->getPrimaryScript() . "\n" .
254 $this->getLanguageScript( $context->getLanguage() ) . "\n" .
255 $this->getSkinScript( $context->getSkin() );
256
257 if ( $context->getDebug() ) {
258 $retval .= $this->getDebugScript();
259 }
260
261 return $retval;
262 }
263
264 public function getStyles( ResourceLoaderContext $context ) {
265 $styles = array();
266 foreach ( $this->getPrimaryStyles() as $media => $style ) {
267 if ( !isset( $styles[$media] ) ) {
268 $styles[$media] = '';
269 }
270 $styles[$media] .= $style;
271 }
272 foreach ( $this->getSkinStyles( $context->getSkin() ) as $media => $style ) {
273 if ( !isset( $styles[$media] ) ) {
274 $styles[$media] = '';
275 }
276 $styles[$media] .= $style;
277 }
278
279 // Collect referenced files
280 $files = array();
281 foreach ( $styles as $style ) {
282 // Extract and store the list of referenced files
283 $files = array_merge( $files, CSSMin::getLocalFileReferences( $style ) );
284 }
285
286 // Only store if modified
287 if ( $files !== $this->getFileDependencies( $context->getSkin() ) ) {
288 $encFiles = FormatJson::encode( $files );
289 $dbw = wfGetDB( DB_MASTER );
290 $dbw->replace( 'module_deps',
291 array( array( 'md_module', 'md_skin' ) ), array(
292 'md_module' => $this->getName(),
293 'md_skin' => $context->getSkin(),
294 'md_deps' => $encFiles,
295 )
296 );
297 }
298
299 return $styles;
300 }
301
302 public function getMessages() {
303 return $this->messages;
304 }
305
306 public function getGroup() {
307 return $this->group;
308 }
309
310 public function getDependencies() {
311 return $this->dependencies;
312 }
313
314 public function getLoaderScript() {
315 if ( count( $this->loaders ) == 0 ) {
316 return false;
317 }
318
319 return self::concatScripts( $this->loaders );
320 }
321
322 /**
323 * Get the last modified timestamp of this module, which is calculated
324 * as the highest last modified timestamp of its constituent files and
325 * the files it depends on (see getFileDependencies()). Only files
326 * relevant to the given language and skin are taken into account, and
327 * files only relevant in debug mode are not taken into account when
328 * debug mode is off.
329 *
330 * @param $context ResourceLoaderContext object
331 * @return Integer: UNIX timestamp
332 */
333 public function getModifiedTime( ResourceLoaderContext $context ) {
334 if ( isset( $this->modifiedTime[$context->getHash()] ) ) {
335 return $this->modifiedTime[$context->getHash()];
336 }
337 wfProfileIn( __METHOD__ );
338
339 // Sort of nasty way we can get a flat list of files depended on by all styles
340 $styles = array();
341 foreach ( self::organizeFilesByOption( $this->styles, 'media', 'all' ) as $styleFiles ) {
342 $styles = array_merge( $styles, $styleFiles );
343 }
344 $skinFiles = (array) self::getSkinFiles(
345 $context->getSkin(), self::organizeFilesByOption( $this->skinStyles, 'media', 'all' )
346 );
347 foreach ( $skinFiles as $styleFiles ) {
348 $styles = array_merge( $styles, $styleFiles );
349 }
350
351 // Final merge, this should result in a master list of dependent files
352 $files = array_merge(
353 $this->scripts,
354 $styles,
355 $context->getDebug() ? $this->debugScripts : array(),
356 isset( $this->languageScripts[$context->getLanguage()] ) ?
357 (array) $this->languageScripts[$context->getLanguage()] : array(),
358 (array) self::getSkinFiles( $context->getSkin(), $this->skinScripts ),
359 $this->loaders,
360 $this->getFileDependencies( $context->getSkin() )
361 );
362
363 wfProfileIn( __METHOD__.'-filemtime' );
364 $filesMtime = max( array_map( 'filemtime', array_map( array( __CLASS__, 'remapFilename' ), $files ) ) );
365 wfProfileOut( __METHOD__.'-filemtime' );
366 $this->modifiedTime[$context->getHash()] = max( $filesMtime, $this->getMsgBlobMtime( $context->getLanguage() ) );
367 wfProfileOut( __METHOD__ );
368 return $this->modifiedTime[$context->getHash()];
369 }
370
371 /* Protected Members */
372
373 /**
374 * Get the primary JS for this module. This is pulled from the
375 * script files added through addScripts()
376 *
377 * @return String: JS
378 */
379 protected function getPrimaryScript() {
380 return self::concatScripts( $this->scripts );
381 }
382
383 /**
384 * Get the primary CSS for this module. This is pulled from the CSS
385 * files added through addStyles()
386 *
387 * @return Array
388 */
389 protected function getPrimaryStyles() {
390 return self::concatStyles( $this->styles );
391 }
392
393 /**
394 * Get the debug JS for this module. This is pulled from the script
395 * files added through addDebugScripts()
396 *
397 * @return String: JS
398 */
399 protected function getDebugScript() {
400 return self::concatScripts( $this->debugScripts );
401 }
402
403 /**
404 * Get the language-specific JS for a given language. This is pulled
405 * from the language-specific script files added through addLanguageScripts()
406 *
407 * @return String: JS
408 */
409 protected function getLanguageScript( $lang ) {
410 if ( !isset( $this->languageScripts[$lang] ) ) {
411 return '';
412 }
413 return self::concatScripts( $this->languageScripts[$lang] );
414 }
415
416 /**
417 * Get the skin-specific JS for a given skin. This is pulled from the
418 * skin-specific JS files added through addSkinScripts()
419 *
420 * @return String: JS
421 */
422 protected function getSkinScript( $skin ) {
423 return self::concatScripts( self::getSkinFiles( $skin, $this->skinScripts ) );
424 }
425
426 /**
427 * Get the skin-specific CSS for a given skin. This is pulled from the
428 * skin-specific CSS files added through addSkinStyles()
429 *
430 * @return Array: list of CSS strings keyed by media type
431 */
432 protected function getSkinStyles( $skin ) {
433 return self::concatStyles( self::getSkinFiles( $skin, $this->skinStyles ) );
434 }
435
436 /**
437 * Helper function to get skin-specific data from an array.
438 *
439 * @param $skin String: skin name
440 * @param $map Array: map of skin names to arrays
441 * @return $map[$skin] if set and non-empty, or $map['default'] if set, or an empty array
442 */
443 protected static function getSkinFiles( $skin, $map ) {
444 $retval = array();
445
446 if ( isset( $map[$skin] ) && $map[$skin] ) {
447 $retval = $map[$skin];
448 } else if ( isset( $map['default'] ) ) {
449 $retval = $map['default'];
450 }
451
452 return $retval;
453 }
454
455 /**
456 * Get the contents of a set of files and concatenate them, with
457 * newlines in between. Each file is used only once.
458 *
459 * @param $files Array of file names
460 * @return String: concatenated contents of $files
461 */
462 protected static function concatScripts( $files ) {
463 return implode( "\n",
464 array_map(
465 'file_get_contents',
466 array_map(
467 array( __CLASS__, 'remapFilename' ),
468 array_unique( (array) $files ) ) ) );
469 }
470
471 protected static function organizeFilesByOption( $files, $option, $default ) {
472 $organizedFiles = array();
473 foreach ( (array) $files as $key => $value ) {
474 if ( is_int( $key ) ) {
475 // File name as the value
476 if ( !isset( $organizedFiles[$default] ) ) {
477 $organizedFiles[$default] = array();
478 }
479 $organizedFiles[$default][] = $value;
480 } else if ( is_array( $value ) ) {
481 // File name as the key, options array as the value
482 $media = isset( $value[$option] ) ? $value[$option] : $default;
483 if ( !isset( $organizedFiles[$media] ) ) {
484 $organizedFiles[$media] = array();
485 }
486 $organizedFiles[$media][] = $key;
487 }
488 }
489 return $organizedFiles;
490 }
491
492 /**
493 * Get the contents of a set of CSS files, remap then and concatenate
494 * them, with newlines in between. Each file is used only once.
495 *
496 * @param $styles Array of file names
497 * @return Array: list of concatenated and remapped contents of $files keyed by media type
498 */
499 protected static function concatStyles( $styles ) {
500 $styles = self::organizeFilesByOption( $styles, 'media', 'all' );
501 foreach ( $styles as $media => $files ) {
502 $styles[$media] =
503 implode( "\n",
504 array_map(
505 array( __CLASS__, 'remapStyle' ),
506 array_unique( (array) $files ) ) );
507 }
508 return $styles;
509 }
510
511 /**
512 * Remap a relative to $IP. Used as a callback for array_map()
513 *
514 * @param $file String: file name
515 * @return string $IP/$file
516 */
517 protected static function remapFilename( $file ) {
518 global $IP;
519
520 return "$IP/$file";
521 }
522
523 /**
524 * Get the contents of a CSS file and run it through CSSMin::remap().
525 * This wrapper is needed so we can use array_map() in concatStyles()
526 *
527 * @param $file String: file name
528 * @return string Remapped CSS
529 */
530 protected static function remapStyle( $file ) {
531 global $wgScriptPath;
532 return CSSMin::remap(
533 file_get_contents( self::remapFilename( $file ) ),
534 dirname( $file ),
535 $wgScriptPath . '/' . dirname( $file ),
536 true
537 );
538 }
539 }