PageRenderTime 56ms CodeModel.GetById 27ms RepoModel.GetById 0ms app.codeStats 0ms

/includes/resourceloader/ResourceLoaderFileModule.php

https://bitbucket.org/brunodefraine/mediawiki
PHP | 656 lines | 300 code | 36 blank | 320 comment | 30 complexity | 8920a8885799b7bfb12295518d2ff794 MD5 | raw file
Possible License(s): GPL-2.0, Apache-2.0, LGPL-3.0
  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. * ResourceLoader module based on local JavaScript/CSS files.
  24. */
  25. class ResourceLoaderFileModule extends ResourceLoaderModule {
  26. /* Protected Members */
  27. /** String: Local base path, see __construct() */
  28. protected $localBasePath = '';
  29. /** String: Remote base path, see __construct() */
  30. protected $remoteBasePath = '';
  31. /**
  32. * Array: List of paths to JavaScript files to always include
  33. * @par Usage:
  34. * @code
  35. * array( [file-path], [file-path], ... )
  36. * @endcode
  37. */
  38. protected $scripts = array();
  39. /**
  40. * Array: List of JavaScript files to include when using a specific language
  41. * @par Usage:
  42. * @code
  43. * array( [language-code] => array( [file-path], [file-path], ... ), ... )
  44. * @endcode
  45. */
  46. protected $languageScripts = array();
  47. /**
  48. * Array: List of JavaScript files to include when using a specific skin
  49. * @par Usage:
  50. * @code
  51. * array( [skin-name] => array( [file-path], [file-path], ... ), ... )
  52. * @endcode
  53. */
  54. protected $skinScripts = array();
  55. /**
  56. * Array: List of paths to JavaScript files to include in debug mode
  57. * @par Usage:
  58. * @code
  59. * array( [skin-name] => array( [file-path], [file-path], ... ), ... )
  60. * @endcode
  61. */
  62. protected $debugScripts = array();
  63. /**
  64. * Array: List of paths to JavaScript files to include in the startup module
  65. * @par Usage:
  66. * @code
  67. * array( [file-path], [file-path], ... )
  68. * @endcode
  69. */
  70. protected $loaderScripts = array();
  71. /**
  72. * Array: List of paths to CSS files to always include
  73. * @par Usage:
  74. * @code
  75. * array( [file-path], [file-path], ... )
  76. * @endcode
  77. */
  78. protected $styles = array();
  79. /**
  80. * Array: List of paths to CSS files to include when using specific skins
  81. * @par Usage:
  82. * @code
  83. * array( [file-path], [file-path], ... )
  84. * @endcode
  85. */
  86. protected $skinStyles = array();
  87. /**
  88. * Array: List of modules this module depends on
  89. * @par Usage:
  90. * @code
  91. * array( [file-path], [file-path], ... )
  92. * @endcode
  93. */
  94. protected $dependencies = array();
  95. /**
  96. * Array: List of message keys used by this module
  97. * @par Usage:
  98. * @code
  99. * array( [message-key], [message-key], ... )
  100. * @endcode
  101. */
  102. protected $messages = array();
  103. /** String: Name of group to load this module in */
  104. protected $group;
  105. /** String: Position on the page to load this module at */
  106. protected $position = 'bottom';
  107. /** Boolean: Link to raw files in debug mode */
  108. protected $debugRaw = true;
  109. /**
  110. * Array: Cache for mtime
  111. * @par Usage:
  112. * @code
  113. * array( [hash] => [mtime], [hash] => [mtime], ... )
  114. * @endcode
  115. */
  116. protected $modifiedTime = array();
  117. /**
  118. * Array: Place where readStyleFile() tracks file dependencies
  119. * @par Usage:
  120. * @code
  121. * array( [file-path], [file-path], ... )
  122. * @endcode
  123. */
  124. protected $localFileRefs = array();
  125. /* Methods */
  126. /**
  127. * Constructs a new module from an options array.
  128. *
  129. * @param $options Array: List of options; if not given or empty, an empty module will be
  130. * constructed
  131. * @param $localBasePath String: Base path to prepend to all local paths in $options. Defaults
  132. * to $IP
  133. * @param $remoteBasePath String: Base path to prepend to all remote paths in $options. Defaults
  134. * to $wgScriptPath
  135. *
  136. * Below is a description for the $options array:
  137. * @par Construction options:
  138. * @code
  139. * array(
  140. * // Base path to prepend to all local paths in $options. Defaults to $IP
  141. * 'localBasePath' => [base path],
  142. * // Base path to prepend to all remote paths in $options. Defaults to $wgScriptPath
  143. * 'remoteBasePath' => [base path],
  144. * // Equivalent of remoteBasePath, but relative to $wgExtensionAssetsPath
  145. * 'remoteExtPath' => [base path],
  146. * // Scripts to always include
  147. * 'scripts' => [file path string or array of file path strings],
  148. * // Scripts to include in specific language contexts
  149. * 'languageScripts' => array(
  150. * [language code] => [file path string or array of file path strings],
  151. * ),
  152. * // Scripts to include in specific skin contexts
  153. * 'skinScripts' => array(
  154. * [skin name] => [file path string or array of file path strings],
  155. * ),
  156. * // Scripts to include in debug contexts
  157. * 'debugScripts' => [file path string or array of file path strings],
  158. * // Scripts to include in the startup module
  159. * 'loaderScripts' => [file path string or array of file path strings],
  160. * // Modules which must be loaded before this module
  161. * 'dependencies' => [modile name string or array of module name strings],
  162. * // Styles to always load
  163. * 'styles' => [file path string or array of file path strings],
  164. * // Styles to include in specific skin contexts
  165. * 'skinStyles' => array(
  166. * [skin name] => [file path string or array of file path strings],
  167. * ),
  168. * // Messages to always load
  169. * 'messages' => [array of message key strings],
  170. * // Group which this module should be loaded together with
  171. * 'group' => [group name string],
  172. * // Position on the page to load this module at
  173. * 'position' => ['bottom' (default) or 'top']
  174. * )
  175. * @endcode
  176. */
  177. public function __construct( $options = array(), $localBasePath = null,
  178. $remoteBasePath = null )
  179. {
  180. global $IP, $wgScriptPath, $wgResourceBasePath;
  181. $this->localBasePath = $localBasePath === null ? $IP : $localBasePath;
  182. if ( $remoteBasePath !== null ) {
  183. $this->remoteBasePath = $remoteBasePath;
  184. } else {
  185. $this->remoteBasePath = $wgResourceBasePath === null ? $wgScriptPath : $wgResourceBasePath;
  186. }
  187. if ( isset( $options['remoteExtPath'] ) ) {
  188. global $wgExtensionAssetsPath;
  189. $this->remoteBasePath = $wgExtensionAssetsPath . '/' . $options['remoteExtPath'];
  190. }
  191. foreach ( $options as $member => $option ) {
  192. switch ( $member ) {
  193. // Lists of file paths
  194. case 'scripts':
  195. case 'debugScripts':
  196. case 'loaderScripts':
  197. case 'styles':
  198. $this->{$member} = (array) $option;
  199. break;
  200. // Collated lists of file paths
  201. case 'languageScripts':
  202. case 'skinScripts':
  203. case 'skinStyles':
  204. if ( !is_array( $option ) ) {
  205. throw new MWException(
  206. "Invalid collated file path list error. " .
  207. "'$option' given, array expected."
  208. );
  209. }
  210. foreach ( $option as $key => $value ) {
  211. if ( !is_string( $key ) ) {
  212. throw new MWException(
  213. "Invalid collated file path list key error. " .
  214. "'$key' given, string expected."
  215. );
  216. }
  217. $this->{$member}[$key] = (array) $value;
  218. }
  219. break;
  220. // Lists of strings
  221. case 'dependencies':
  222. case 'messages':
  223. $this->{$member} = (array) $option;
  224. break;
  225. // Single strings
  226. case 'group':
  227. case 'position':
  228. case 'localBasePath':
  229. case 'remoteBasePath':
  230. $this->{$member} = (string) $option;
  231. break;
  232. // Single booleans
  233. case 'debugRaw':
  234. $this->{$member} = (bool) $option;
  235. break;
  236. }
  237. }
  238. // Make sure the remote base path is a complete valid URL,
  239. // but possibly protocol-relative to avoid cache pollution
  240. $this->remoteBasePath = wfExpandUrl( $this->remoteBasePath, PROTO_RELATIVE );
  241. }
  242. /**
  243. * Gets all scripts for a given context concatenated together.
  244. *
  245. * @param $context ResourceLoaderContext: Context in which to generate script
  246. * @return String: JavaScript code for $context
  247. */
  248. public function getScript( ResourceLoaderContext $context ) {
  249. $files = $this->getScriptFiles( $context );
  250. return $this->readScriptFiles( $files );
  251. }
  252. /**
  253. * @param $context ResourceLoaderContext
  254. * @return array
  255. */
  256. public function getScriptURLsForDebug( ResourceLoaderContext $context ) {
  257. $urls = array();
  258. foreach ( $this->getScriptFiles( $context ) as $file ) {
  259. $urls[] = $this->getRemotePath( $file );
  260. }
  261. return $urls;
  262. }
  263. /**
  264. * @return bool
  265. */
  266. public function supportsURLLoading() {
  267. return $this->debugRaw;
  268. }
  269. /**
  270. * Gets loader script.
  271. *
  272. * @return String: JavaScript code to be added to startup module
  273. */
  274. public function getLoaderScript() {
  275. if ( count( $this->loaderScripts ) == 0 ) {
  276. return false;
  277. }
  278. return $this->readScriptFiles( $this->loaderScripts );
  279. }
  280. /**
  281. * Gets all styles for a given context concatenated together.
  282. *
  283. * @param $context ResourceLoaderContext: Context in which to generate styles
  284. * @return String: CSS code for $context
  285. */
  286. public function getStyles( ResourceLoaderContext $context ) {
  287. $styles = $this->readStyleFiles(
  288. $this->getStyleFiles( $context ),
  289. $this->getFlip( $context )
  290. );
  291. // Collect referenced files
  292. $this->localFileRefs = array_unique( $this->localFileRefs );
  293. // If the list has been modified since last time we cached it, update the cache
  294. if ( $this->localFileRefs !== $this->getFileDependencies( $context->getSkin() ) && !wfReadOnly() ) {
  295. $dbw = wfGetDB( DB_MASTER );
  296. $dbw->replace( 'module_deps',
  297. array( array( 'md_module', 'md_skin' ) ), array(
  298. 'md_module' => $this->getName(),
  299. 'md_skin' => $context->getSkin(),
  300. 'md_deps' => FormatJson::encode( $this->localFileRefs ),
  301. )
  302. );
  303. }
  304. return $styles;
  305. }
  306. /**
  307. * @param $context ResourceLoaderContext
  308. * @return array
  309. */
  310. public function getStyleURLsForDebug( ResourceLoaderContext $context ) {
  311. $urls = array();
  312. foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
  313. $urls[$mediaType] = array();
  314. foreach ( $list as $file ) {
  315. $urls[$mediaType][] = $this->getRemotePath( $file );
  316. }
  317. }
  318. return $urls;
  319. }
  320. /**
  321. * Gets list of message keys used by this module.
  322. *
  323. * @return Array: List of message keys
  324. */
  325. public function getMessages() {
  326. return $this->messages;
  327. }
  328. /**
  329. * Gets the name of the group this module should be loaded in.
  330. *
  331. * @return String: Group name
  332. */
  333. public function getGroup() {
  334. return $this->group;
  335. }
  336. /**
  337. * @return string
  338. */
  339. public function getPosition() {
  340. return $this->position;
  341. }
  342. /**
  343. * Gets list of names of modules this module depends on.
  344. *
  345. * @return Array: List of module names
  346. */
  347. public function getDependencies() {
  348. return $this->dependencies;
  349. }
  350. /**
  351. * Get the last modified timestamp of this module.
  352. *
  353. * Last modified timestamps are calculated from the highest last modified
  354. * timestamp of this module's constituent files as well as the files it
  355. * depends on. This function is context-sensitive, only performing
  356. * calculations on files relevant to the given language, skin and debug
  357. * mode.
  358. *
  359. * @param $context ResourceLoaderContext: Context in which to calculate
  360. * the modified time
  361. * @return Integer: UNIX timestamp
  362. * @see ResourceLoaderModule::getFileDependencies
  363. */
  364. public function getModifiedTime( ResourceLoaderContext $context ) {
  365. if ( isset( $this->modifiedTime[$context->getHash()] ) ) {
  366. return $this->modifiedTime[$context->getHash()];
  367. }
  368. wfProfileIn( __METHOD__ );
  369. $files = array();
  370. // Flatten style files into $files
  371. $styles = self::collateFilePathListByOption( $this->styles, 'media', 'all' );
  372. foreach ( $styles as $styleFiles ) {
  373. $files = array_merge( $files, $styleFiles );
  374. }
  375. $skinFiles = self::tryForKey(
  376. self::collateFilePathListByOption( $this->skinStyles, 'media', 'all' ),
  377. $context->getSkin(),
  378. 'default'
  379. );
  380. foreach ( $skinFiles as $styleFiles ) {
  381. $files = array_merge( $files, $styleFiles );
  382. }
  383. // Final merge, this should result in a master list of dependent files
  384. $files = array_merge(
  385. $files,
  386. $this->scripts,
  387. $context->getDebug() ? $this->debugScripts : array(),
  388. self::tryForKey( $this->languageScripts, $context->getLanguage() ),
  389. self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ),
  390. $this->loaderScripts
  391. );
  392. $files = array_map( array( $this, 'getLocalPath' ), $files );
  393. // File deps need to be treated separately because they're already prefixed
  394. $files = array_merge( $files, $this->getFileDependencies( $context->getSkin() ) );
  395. // If a module is nothing but a list of dependencies, we need to avoid
  396. // giving max() an empty array
  397. if ( count( $files ) === 0 ) {
  398. wfProfileOut( __METHOD__ );
  399. return $this->modifiedTime[$context->getHash()] = 1;
  400. }
  401. wfProfileIn( __METHOD__.'-filemtime' );
  402. $filesMtime = max( array_map( array( __CLASS__, 'safeFilemtime' ), $files ) );
  403. wfProfileOut( __METHOD__.'-filemtime' );
  404. $this->modifiedTime[$context->getHash()] = max(
  405. $filesMtime,
  406. $this->getMsgBlobMtime( $context->getLanguage() ) );
  407. wfProfileOut( __METHOD__ );
  408. return $this->modifiedTime[$context->getHash()];
  409. }
  410. /* Protected Methods */
  411. /**
  412. * @param $path string
  413. * @return string
  414. */
  415. protected function getLocalPath( $path ) {
  416. return "{$this->localBasePath}/$path";
  417. }
  418. /**
  419. * @param $path string
  420. * @return string
  421. */
  422. protected function getRemotePath( $path ) {
  423. return "{$this->remoteBasePath}/$path";
  424. }
  425. /**
  426. * Collates file paths by option (where provided).
  427. *
  428. * @param $list Array: List of file paths in any combination of index/path
  429. * or path/options pairs
  430. * @param $option String: option name
  431. * @param $default Mixed: default value if the option isn't set
  432. * @return Array: List of file paths, collated by $option
  433. */
  434. protected static function collateFilePathListByOption( array $list, $option, $default ) {
  435. $collatedFiles = array();
  436. foreach ( (array) $list as $key => $value ) {
  437. if ( is_int( $key ) ) {
  438. // File name as the value
  439. if ( !isset( $collatedFiles[$default] ) ) {
  440. $collatedFiles[$default] = array();
  441. }
  442. $collatedFiles[$default][] = $value;
  443. } elseif ( is_array( $value ) ) {
  444. // File name as the key, options array as the value
  445. $optionValue = isset( $value[$option] ) ? $value[$option] : $default;
  446. if ( !isset( $collatedFiles[$optionValue] ) ) {
  447. $collatedFiles[$optionValue] = array();
  448. }
  449. $collatedFiles[$optionValue][] = $key;
  450. }
  451. }
  452. return $collatedFiles;
  453. }
  454. /**
  455. * Gets a list of element that match a key, optionally using a fallback key.
  456. *
  457. * @param $list Array: List of lists to select from
  458. * @param $key String: Key to look for in $map
  459. * @param $fallback String: Key to look for in $list if $key doesn't exist
  460. * @return Array: List of elements from $map which matched $key or $fallback,
  461. * or an empty list in case of no match
  462. */
  463. protected static function tryForKey( array $list, $key, $fallback = null ) {
  464. if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
  465. return $list[$key];
  466. } elseif ( is_string( $fallback )
  467. && isset( $list[$fallback] )
  468. && is_array( $list[$fallback] ) )
  469. {
  470. return $list[$fallback];
  471. }
  472. return array();
  473. }
  474. /**
  475. * Gets a list of file paths for all scripts in this module, in order of propper execution.
  476. *
  477. * @param $context ResourceLoaderContext: Context
  478. * @return Array: List of file paths
  479. */
  480. protected function getScriptFiles( ResourceLoaderContext $context ) {
  481. $files = array_merge(
  482. $this->scripts,
  483. self::tryForKey( $this->languageScripts, $context->getLanguage() ),
  484. self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
  485. );
  486. if ( $context->getDebug() ) {
  487. $files = array_merge( $files, $this->debugScripts );
  488. }
  489. return $files;
  490. }
  491. /**
  492. * Gets a list of file paths for all styles in this module, in order of propper inclusion.
  493. *
  494. * @param $context ResourceLoaderContext: Context
  495. * @return Array: List of file paths
  496. */
  497. protected function getStyleFiles( ResourceLoaderContext $context ) {
  498. return array_merge_recursive(
  499. self::collateFilePathListByOption( $this->styles, 'media', 'all' ),
  500. self::collateFilePathListByOption(
  501. self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' ), 'media', 'all'
  502. )
  503. );
  504. }
  505. /**
  506. * Gets the contents of a list of JavaScript files.
  507. *
  508. * @param $scripts Array: List of file paths to scripts to read, remap and concetenate
  509. * @return String: Concatenated and remapped JavaScript data from $scripts
  510. */
  511. protected function readScriptFiles( array $scripts ) {
  512. global $wgResourceLoaderValidateStaticJS;
  513. if ( empty( $scripts ) ) {
  514. return '';
  515. }
  516. $js = '';
  517. foreach ( array_unique( $scripts ) as $fileName ) {
  518. $localPath = $this->getLocalPath( $fileName );
  519. if ( !file_exists( $localPath ) ) {
  520. throw new MWException( __METHOD__.": script file not found: \"$localPath\"" );
  521. }
  522. $contents = file_get_contents( $localPath );
  523. if ( $wgResourceLoaderValidateStaticJS ) {
  524. // Static files don't really need to be checked as often; unlike
  525. // on-wiki module they shouldn't change unexpectedly without
  526. // admin interference.
  527. $contents = $this->validateScriptFile( $fileName, $contents );
  528. }
  529. $js .= $contents . "\n";
  530. }
  531. return $js;
  532. }
  533. /**
  534. * Gets the contents of a list of CSS files.
  535. *
  536. * @param $styles Array: List of media type/list of file paths pairs, to read, remap and
  537. * concetenate
  538. *
  539. * @param $flip bool
  540. *
  541. * @return Array: List of concatenated and remapped CSS data from $styles,
  542. * keyed by media type
  543. */
  544. protected function readStyleFiles( array $styles, $flip ) {
  545. if ( empty( $styles ) ) {
  546. return array();
  547. }
  548. foreach ( $styles as $media => $files ) {
  549. $uniqueFiles = array_unique( $files );
  550. $styles[$media] = implode(
  551. "\n",
  552. array_map(
  553. array( $this, 'readStyleFile' ),
  554. $uniqueFiles,
  555. array_fill( 0, count( $uniqueFiles ), $flip )
  556. )
  557. );
  558. }
  559. return $styles;
  560. }
  561. /**
  562. * Reads a style file.
  563. *
  564. * This method can be used as a callback for array_map()
  565. *
  566. * @param $path String: File path of style file to read
  567. * @param $flip bool
  568. *
  569. * @return String: CSS data in script file
  570. * @throws MWException if the file doesn't exist
  571. */
  572. protected function readStyleFile( $path, $flip ) {
  573. $localPath = $this->getLocalPath( $path );
  574. if ( !file_exists( $localPath ) ) {
  575. throw new MWException( __METHOD__.": style file not found: \"$localPath\"" );
  576. }
  577. $style = file_get_contents( $localPath );
  578. if ( $flip ) {
  579. $style = CSSJanus::transform( $style, true, false );
  580. }
  581. $dirname = dirname( $path );
  582. if ( $dirname == '.' ) {
  583. // If $path doesn't have a directory component, don't prepend a dot
  584. $dirname = '';
  585. }
  586. $dir = $this->getLocalPath( $dirname );
  587. $remoteDir = $this->getRemotePath( $dirname );
  588. // Get and register local file references
  589. $this->localFileRefs = array_merge(
  590. $this->localFileRefs,
  591. CSSMin::getLocalFileReferences( $style, $dir ) );
  592. return CSSMin::remap(
  593. $style, $dir, $remoteDir, true
  594. );
  595. }
  596. /**
  597. * Safe version of filemtime(), which doesn't throw a PHP warning if the file doesn't exist
  598. * but returns 1 instead.
  599. * @param $filename string File name
  600. * @return int UNIX timestamp, or 1 if the file doesn't exist
  601. */
  602. protected static function safeFilemtime( $filename ) {
  603. if ( file_exists( $filename ) ) {
  604. return filemtime( $filename );
  605. } else {
  606. // We only ever map this function on an array if we're gonna call max() after,
  607. // so return our standard minimum timestamps here. This is 1, not 0, because
  608. // wfTimestamp(0) == NOW
  609. return 1;
  610. }
  611. }
  612. /**
  613. * Get whether CSS for this module should be flipped
  614. * @param $context ResourceLoaderContext
  615. * @return bool
  616. */
  617. public function getFlip( $context ) {
  618. return $context->getDirection() === 'rtl';
  619. }
  620. }