AssetController.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. <?php
  2. /**
  3. * @link http://www.yiiframework.com/
  4. * @copyright Copyright (c) 2008 Yii Software LLC
  5. * @license http://www.yiiframework.com/license/
  6. */
  7. namespace yii\console\controllers;
  8. use Yii;
  9. use yii\console\Exception;
  10. use yii\console\Controller;
  11. /**
  12. * This command allows you to combine and compress your JavaScript and CSS files.
  13. *
  14. * Usage:
  15. * 1. Create a configuration file using 'template' action:
  16. * yii asset/template /path/to/myapp/config.php
  17. * 2. Edit the created config file, adjusting it for your web application needs.
  18. * 3. Run the 'compress' action, using created config:
  19. * yii asset /path/to/myapp/config.php /path/to/myapp/config/assets_compressed.php
  20. * 4. Adjust your web application config to use compressed assets.
  21. *
  22. * Note: in the console environment some path aliases like '@webroot' and '@web' may not exist,
  23. * so corresponding paths inside the configuration should be specified directly.
  24. *
  25. * Note: by default this command relies on an external tools to perform actual files compression,
  26. * check [[jsCompressor]] and [[cssCompressor]] for more details.
  27. *
  28. * @property \yii\web\AssetManager $assetManager Asset manager instance. Note that the type of this property
  29. * differs in getter and setter. See [[getAssetManager()]] and [[setAssetManager()]] for details.
  30. *
  31. * @author Qiang Xue <[email protected]>
  32. * @since 2.0
  33. */
  34. class AssetController extends Controller
  35. {
  36. /**
  37. * @var string controller default action ID.
  38. */
  39. public $defaultAction = 'compress';
  40. /**
  41. * @var array list of asset bundles to be compressed.
  42. */
  43. public $bundles = [];
  44. /**
  45. * @var array list of asset bundles, which represents output compressed files.
  46. * You can specify the name of the output compressed file using 'css' and 'js' keys:
  47. * For example:
  48. *
  49. * ~~~
  50. * 'app\config\AllAsset' => [
  51. * 'js' => 'js/all-{ts}.js',
  52. * 'css' => 'css/all-{ts}.css',
  53. * 'depends' => [ ... ],
  54. * ]
  55. * ~~~
  56. *
  57. * File names can contain placeholder "{ts}", which will be filled by current timestamp, while
  58. * file creation.
  59. */
  60. public $targets = [];
  61. /**
  62. * @var string|callback JavaScript file compressor.
  63. * If a string, it is treated as shell command template, which should contain
  64. * placeholders {from} - source file name - and {to} - output file name.
  65. * Otherwise, it is treated as PHP callback, which should perform the compression.
  66. *
  67. * Default value relies on usage of "Closure Compiler"
  68. * @see https://developers.google.com/closure/compiler/
  69. */
  70. public $jsCompressor = 'java -jar compiler.jar --js {from} --js_output_file {to}';
  71. /**
  72. * @var string|callback CSS file compressor.
  73. * If a string, it is treated as shell command template, which should contain
  74. * placeholders {from} - source file name - and {to} - output file name.
  75. * Otherwise, it is treated as PHP callback, which should perform the compression.
  76. *
  77. * Default value relies on usage of "YUI Compressor"
  78. * @see https://github.com/yui/yuicompressor/
  79. */
  80. public $cssCompressor = 'java -jar yuicompressor.jar {from} -o {to}';
  81. /**
  82. * @var array|\yii\web\AssetManager [[yii\web\AssetManager]] instance or its array configuration, which will be used
  83. * for assets processing.
  84. */
  85. private $_assetManager = [];
  86. /**
  87. * Returns the asset manager instance.
  88. * @throws \yii\console\Exception on invalid configuration.
  89. * @return \yii\web\AssetManager asset manager instance.
  90. */
  91. public function getAssetManager()
  92. {
  93. if (!is_object($this->_assetManager)) {
  94. $options = $this->_assetManager;
  95. if (!isset($options['class'])) {
  96. $options['class'] = 'yii\\web\\AssetManager';
  97. }
  98. if (!isset($options['basePath'])) {
  99. throw new Exception("Please specify 'basePath' for the 'assetManager' option.");
  100. }
  101. if (!isset($options['baseUrl'])) {
  102. throw new Exception("Please specify 'baseUrl' for the 'assetManager' option.");
  103. }
  104. $this->_assetManager = Yii::createObject($options);
  105. }
  106. return $this->_assetManager;
  107. }
  108. /**
  109. * Sets asset manager instance or configuration.
  110. * @param \yii\web\AssetManager|array $assetManager asset manager instance or its array configuration.
  111. * @throws \yii\console\Exception on invalid argument type.
  112. */
  113. public function setAssetManager($assetManager)
  114. {
  115. if (is_scalar($assetManager)) {
  116. throw new Exception('"' . get_class($this) . '::assetManager" should be either object or array - "' . gettype($assetManager) . '" given.');
  117. }
  118. $this->_assetManager = $assetManager;
  119. }
  120. /**
  121. * Combines and compresses the asset files according to the given configuration.
  122. * During the process new asset bundle configuration file will be created.
  123. * You should replace your original asset bundle configuration with this file in order to use compressed files.
  124. * @param string $configFile configuration file name.
  125. * @param string $bundleFile output asset bundles configuration file name.
  126. */
  127. public function actionCompress($configFile, $bundleFile)
  128. {
  129. $this->loadConfiguration($configFile);
  130. $bundles = $this->loadBundles($this->bundles);
  131. $targets = $this->loadTargets($this->targets, $bundles);
  132. $timestamp = time();
  133. foreach ($targets as $name => $target) {
  134. echo "Creating output bundle '{$name}':\n";
  135. if (!empty($target->js)) {
  136. $this->buildTarget($target, 'js', $bundles, $timestamp);
  137. }
  138. if (!empty($target->css)) {
  139. $this->buildTarget($target, 'css', $bundles, $timestamp);
  140. }
  141. echo "\n";
  142. }
  143. $targets = $this->adjustDependency($targets, $bundles);
  144. $this->saveTargets($targets, $bundleFile);
  145. }
  146. /**
  147. * Applies configuration from the given file to self instance.
  148. * @param string $configFile configuration file name.
  149. * @throws \yii\console\Exception on failure.
  150. */
  151. protected function loadConfiguration($configFile)
  152. {
  153. echo "Loading configuration from '{$configFile}'...\n";
  154. foreach (require($configFile) as $name => $value) {
  155. if (property_exists($this, $name) || $this->canSetProperty($name)) {
  156. $this->$name = $value;
  157. } else {
  158. throw new Exception("Unknown configuration option: $name");
  159. }
  160. }
  161. $this->getAssetManager(); // check if asset manager configuration is correct
  162. }
  163. /**
  164. * Creates full list of source asset bundles.
  165. * @param string[] $bundles list of asset bundle names
  166. * @return \yii\web\AssetBundle[] list of source asset bundles.
  167. */
  168. protected function loadBundles($bundles)
  169. {
  170. echo "Collecting source bundles information...\n";
  171. $am = $this->getAssetManager();
  172. $result = [];
  173. foreach ($bundles as $name) {
  174. $result[$name] = $am->getBundle($name);
  175. }
  176. foreach ($result as $bundle) {
  177. $this->loadDependency($bundle, $result);
  178. }
  179. return $result;
  180. }
  181. /**
  182. * Loads asset bundle dependencies recursively.
  183. * @param \yii\web\AssetBundle $bundle bundle instance
  184. * @param array $result already loaded bundles list.
  185. * @throws Exception on failure.
  186. */
  187. protected function loadDependency($bundle, &$result)
  188. {
  189. $am = $this->getAssetManager();
  190. foreach ($bundle->depends as $name) {
  191. if (!isset($result[$name])) {
  192. $dependencyBundle = $am->getBundle($name);
  193. $result[$name] = false;
  194. $this->loadDependency($dependencyBundle, $result);
  195. $result[$name] = $dependencyBundle;
  196. } elseif ($result[$name] === false) {
  197. throw new Exception("A circular dependency is detected for bundle '$name'.");
  198. }
  199. }
  200. }
  201. /**
  202. * Creates full list of output asset bundles.
  203. * @param array $targets output asset bundles configuration.
  204. * @param \yii\web\AssetBundle[] $bundles list of source asset bundles.
  205. * @return \yii\web\AssetBundle[] list of output asset bundles.
  206. * @throws Exception on failure.
  207. */
  208. protected function loadTargets($targets, $bundles)
  209. {
  210. // build the dependency order of bundles
  211. $registered = [];
  212. foreach ($bundles as $name => $bundle) {
  213. $this->registerBundle($bundles, $name, $registered);
  214. }
  215. $bundleOrders = array_combine(array_keys($registered), range(0, count($bundles) - 1));
  216. // fill up the target which has empty 'depends'.
  217. $referenced = [];
  218. foreach ($targets as $name => $target) {
  219. if (empty($target['depends'])) {
  220. if (!isset($all)) {
  221. $all = $name;
  222. } else {
  223. throw new Exception("Only one target can have empty 'depends' option. Found two now: $all, $name");
  224. }
  225. } else {
  226. foreach ($target['depends'] as $bundle) {
  227. if (!isset($referenced[$bundle])) {
  228. $referenced[$bundle] = $name;
  229. } else {
  230. throw new Exception("Target '{$referenced[$bundle]}' and '$name' cannot contain the bundle '$bundle' at the same time.");
  231. }
  232. }
  233. }
  234. }
  235. if (isset($all)) {
  236. $targets[$all]['depends'] = array_diff(array_keys($registered), array_keys($referenced));
  237. }
  238. // adjust the 'depends' order for each target according to the dependency order of bundles
  239. // create an AssetBundle object for each target
  240. foreach ($targets as $name => $target) {
  241. if (!isset($target['basePath'])) {
  242. throw new Exception("Please specify 'basePath' for the '$name' target.");
  243. }
  244. if (!isset($target['baseUrl'])) {
  245. throw new Exception("Please specify 'baseUrl' for the '$name' target.");
  246. }
  247. usort($target['depends'], function ($a, $b) use ($bundleOrders) {
  248. if ($bundleOrders[$a] == $bundleOrders[$b]) {
  249. return 0;
  250. } else {
  251. return $bundleOrders[$a] > $bundleOrders[$b] ? 1 : -1;
  252. }
  253. });
  254. $target['class'] = $name;
  255. $targets[$name] = Yii::createObject($target);
  256. }
  257. return $targets;
  258. }
  259. /**
  260. * Builds output asset bundle.
  261. * @param \yii\web\AssetBundle $target output asset bundle
  262. * @param string $type either 'js' or 'css'.
  263. * @param \yii\web\AssetBundle[] $bundles source asset bundles.
  264. * @param integer $timestamp current timestamp.
  265. * @throws Exception on failure.
  266. */
  267. protected function buildTarget($target, $type, $bundles, $timestamp)
  268. {
  269. $outputFile = strtr($target->$type, [
  270. '{ts}' => $timestamp,
  271. ]);
  272. $inputFiles = [];
  273. foreach ($target->depends as $name) {
  274. if (isset($bundles[$name])) {
  275. foreach ($bundles[$name]->$type as $file) {
  276. $inputFiles[] = $bundles[$name]->basePath . '/' . $file;
  277. }
  278. } else {
  279. throw new Exception("Unknown bundle: '{$name}'");
  280. }
  281. }
  282. if ($type === 'js') {
  283. $this->compressJsFiles($inputFiles, $target->basePath . '/' . $outputFile);
  284. } else {
  285. $this->compressCssFiles($inputFiles, $target->basePath . '/' . $outputFile);
  286. }
  287. $target->$type = [$outputFile];
  288. }
  289. /**
  290. * Adjust dependencies between asset bundles in the way source bundles begin to depend on output ones.
  291. * @param \yii\web\AssetBundle[] $targets output asset bundles.
  292. * @param \yii\web\AssetBundle[] $bundles source asset bundles.
  293. * @return \yii\web\AssetBundle[] output asset bundles.
  294. */
  295. protected function adjustDependency($targets, $bundles)
  296. {
  297. echo "Creating new bundle configuration...\n";
  298. $map = [];
  299. foreach ($targets as $name => $target) {
  300. foreach ($target->depends as $bundle) {
  301. $map[$bundle] = $name;
  302. }
  303. }
  304. foreach ($targets as $name => $target) {
  305. $depends = [];
  306. foreach ($target->depends as $bn) {
  307. foreach ($bundles[$bn]->depends as $bundle) {
  308. $depends[$map[$bundle]] = true;
  309. }
  310. }
  311. unset($depends[$name]);
  312. $target->depends = array_keys($depends);
  313. }
  314. // detect possible circular dependencies
  315. foreach ($targets as $name => $target) {
  316. $registered = [];
  317. $this->registerBundle($targets, $name, $registered);
  318. }
  319. foreach ($map as $bundle => $target) {
  320. $targets[$bundle] = Yii::createObject([
  321. 'class' => 'yii\\web\\AssetBundle',
  322. 'depends' => [$target],
  323. ]);
  324. }
  325. return $targets;
  326. }
  327. /**
  328. * Registers asset bundles including their dependencies.
  329. * @param \yii\web\AssetBundle[] $bundles asset bundles list.
  330. * @param string $name bundle name.
  331. * @param array $registered stores already registered names.
  332. * @throws Exception if circular dependency is detected.
  333. */
  334. protected function registerBundle($bundles, $name, &$registered)
  335. {
  336. if (!isset($registered[$name])) {
  337. $registered[$name] = false;
  338. $bundle = $bundles[$name];
  339. foreach ($bundle->depends as $depend) {
  340. $this->registerBundle($bundles, $depend, $registered);
  341. }
  342. unset($registered[$name]);
  343. $registered[$name] = true;
  344. } elseif ($registered[$name] === false) {
  345. throw new Exception("A circular dependency is detected for target '$name'.");
  346. }
  347. }
  348. /**
  349. * Saves new asset bundles configuration.
  350. * @param \yii\web\AssetBundle[] $targets list of asset bundles to be saved.
  351. * @param string $bundleFile output file name.
  352. * @throws \yii\console\Exception on failure.
  353. */
  354. protected function saveTargets($targets, $bundleFile)
  355. {
  356. $array = [];
  357. foreach ($targets as $name => $target) {
  358. foreach (['basePath', 'baseUrl', 'js', 'css', 'depends'] as $prop) {
  359. if (!empty($target->$prop)) {
  360. $array[$name][$prop] = $target->$prop;
  361. } elseif (in_array($prop, ['js', 'css'])) {
  362. $array[$name][$prop] = [];
  363. }
  364. }
  365. }
  366. $array = var_export($array, true);
  367. $version = date('Y-m-d H:i:s', time());
  368. $bundleFileContent = <<<EOD
  369. <?php
  370. /**
  371. * This file is generated by the "yii {$this->id}" command.
  372. * DO NOT MODIFY THIS FILE DIRECTLY.
  373. * @version {$version}
  374. */
  375. return {$array};
  376. EOD;
  377. if (!file_put_contents($bundleFile, $bundleFileContent)) {
  378. throw new Exception("Unable to write output bundle configuration at '{$bundleFile}'.");
  379. }
  380. echo "Output bundle configuration created at '{$bundleFile}'.\n";
  381. }
  382. /**
  383. * Compresses given JavaScript files and combines them into the single one.
  384. * @param array $inputFiles list of source file names.
  385. * @param string $outputFile output file name.
  386. * @throws \yii\console\Exception on failure
  387. */
  388. protected function compressJsFiles($inputFiles, $outputFile)
  389. {
  390. if (empty($inputFiles)) {
  391. return;
  392. }
  393. echo " Compressing JavaScript files...\n";
  394. if (is_string($this->jsCompressor)) {
  395. $tmpFile = $outputFile . '.tmp';
  396. $this->combineJsFiles($inputFiles, $tmpFile);
  397. echo shell_exec(strtr($this->jsCompressor, [
  398. '{from}' => escapeshellarg($tmpFile),
  399. '{to}' => escapeshellarg($outputFile),
  400. ]));
  401. @unlink($tmpFile);
  402. } else {
  403. call_user_func($this->jsCompressor, $this, $inputFiles, $outputFile);
  404. }
  405. if (!file_exists($outputFile)) {
  406. throw new Exception("Unable to compress JavaScript files into '{$outputFile}'.");
  407. }
  408. echo " JavaScript files compressed into '{$outputFile}'.\n";
  409. }
  410. /**
  411. * Compresses given CSS files and combines them into the single one.
  412. * @param array $inputFiles list of source file names.
  413. * @param string $outputFile output file name.
  414. * @throws \yii\console\Exception on failure
  415. */
  416. protected function compressCssFiles($inputFiles, $outputFile)
  417. {
  418. if (empty($inputFiles)) {
  419. return;
  420. }
  421. echo " Compressing CSS files...\n";
  422. if (is_string($this->cssCompressor)) {
  423. $tmpFile = $outputFile . '.tmp';
  424. $this->combineCssFiles($inputFiles, $tmpFile);
  425. echo shell_exec(strtr($this->cssCompressor, [
  426. '{from}' => escapeshellarg($tmpFile),
  427. '{to}' => escapeshellarg($outputFile),
  428. ]));
  429. @unlink($tmpFile);
  430. } else {
  431. call_user_func($this->cssCompressor, $this, $inputFiles, $outputFile);
  432. }
  433. if (!file_exists($outputFile)) {
  434. throw new Exception("Unable to compress CSS files into '{$outputFile}'.");
  435. }
  436. echo " CSS files compressed into '{$outputFile}'.\n";
  437. }
  438. /**
  439. * Combines JavaScript files into a single one.
  440. * @param array $inputFiles source file names.
  441. * @param string $outputFile output file name.
  442. * @throws \yii\console\Exception on failure.
  443. */
  444. public function combineJsFiles($inputFiles, $outputFile)
  445. {
  446. $content = '';
  447. foreach ($inputFiles as $file) {
  448. $content .= "/*** BEGIN FILE: $file ***/\n"
  449. . file_get_contents($file)
  450. . "/*** END FILE: $file ***/\n";
  451. }
  452. if (!file_put_contents($outputFile, $content)) {
  453. throw new Exception("Unable to write output JavaScript file '{$outputFile}'.");
  454. }
  455. }
  456. /**
  457. * Combines CSS files into a single one.
  458. * @param array $inputFiles source file names.
  459. * @param string $outputFile output file name.
  460. * @throws \yii\console\Exception on failure.
  461. */
  462. public function combineCssFiles($inputFiles, $outputFile)
  463. {
  464. $content = '';
  465. foreach ($inputFiles as $file) {
  466. $content .= "/*** BEGIN FILE: $file ***/\n"
  467. . $this->adjustCssUrl(file_get_contents($file), dirname($file), dirname($outputFile))
  468. . "/*** END FILE: $file ***/\n";
  469. }
  470. if (!file_put_contents($outputFile, $content)) {
  471. throw new Exception("Unable to write output CSS file '{$outputFile}'.");
  472. }
  473. }
  474. /**
  475. * Adjusts CSS content allowing URL references pointing to the original resources.
  476. * @param string $cssContent source CSS content.
  477. * @param string $inputFilePath input CSS file name.
  478. * @param string $outputFilePath output CSS file name.
  479. * @return string adjusted CSS content.
  480. */
  481. protected function adjustCssUrl($cssContent, $inputFilePath, $outputFilePath)
  482. {
  483. $sharedPathParts = [];
  484. $inputFilePathParts = explode('/', $inputFilePath);
  485. $inputFilePathPartsCount = count($inputFilePathParts);
  486. $outputFilePathParts = explode('/', $outputFilePath);
  487. $outputFilePathPartsCount = count($outputFilePathParts);
  488. for ($i =0; $i < $inputFilePathPartsCount && $i < $outputFilePathPartsCount; $i++) {
  489. if ($inputFilePathParts[$i] == $outputFilePathParts[$i]) {
  490. $sharedPathParts[] = $inputFilePathParts[$i];
  491. } else {
  492. break;
  493. }
  494. }
  495. $sharedPath = implode('/', $sharedPathParts);
  496. $inputFileRelativePath = trim(str_replace($sharedPath, '', $inputFilePath), '/');
  497. $outputFileRelativePath = trim(str_replace($sharedPath, '', $outputFilePath), '/');
  498. $inputFileRelativePathParts = explode('/', $inputFileRelativePath);
  499. $outputFileRelativePathParts = explode('/', $outputFileRelativePath);
  500. $callback = function ($matches) use ($inputFileRelativePathParts, $outputFileRelativePathParts) {
  501. $fullMatch = $matches[0];
  502. $inputUrl = $matches[1];
  503. if (preg_match('/https?:\/\//is', $inputUrl)) {
  504. return $fullMatch;
  505. }
  506. $outputUrlParts = array_fill(0, count($outputFileRelativePathParts), '..');
  507. $outputUrlParts = array_merge($outputUrlParts, $inputFileRelativePathParts);
  508. if (strpos($inputUrl, '/') !== false) {
  509. $inputUrlParts = explode('/', $inputUrl);
  510. foreach ($inputUrlParts as $key => $inputUrlPart) {
  511. if ($inputUrlPart == '..') {
  512. array_pop($outputUrlParts);
  513. unset($inputUrlParts[$key]);
  514. }
  515. }
  516. $outputUrlParts[] = implode('/', $inputUrlParts);
  517. } else {
  518. $outputUrlParts[] = $inputUrl;
  519. }
  520. $outputUrl = implode('/', $outputUrlParts);
  521. return str_replace($inputUrl, $outputUrl, $fullMatch);
  522. };
  523. $cssContent = preg_replace_callback('/url\(["\']?([^"]*)["\']?\)/is', $callback, $cssContent);
  524. return $cssContent;
  525. }
  526. /**
  527. * Creates template of configuration file for [[actionCompress]].
  528. * @param string $configFile output file name.
  529. * @throws \yii\console\Exception on failure.
  530. */
  531. public function actionTemplate($configFile)
  532. {
  533. $template = <<<EOD
  534. <?php
  535. /**
  536. * Configuration file for the "yii asset" console command.
  537. * Note that in the console environment, some path aliases like '@webroot' and '@web' may not exist.
  538. * Please define these missing path aliases.
  539. */
  540. return [
  541. // The list of asset bundles to compress:
  542. 'bundles' => [
  543. // 'yii\web\YiiAsset',
  544. // 'yii\web\JqueryAsset',
  545. ],
  546. // Asset bundle for compression output:
  547. 'targets' => [
  548. 'app\config\AllAsset' => [
  549. 'basePath' => 'path/to/web',
  550. 'baseUrl' => '',
  551. 'js' => 'js/all-{ts}.js',
  552. 'css' => 'css/all-{ts}.css',
  553. ],
  554. ],
  555. // Asset manager configuration:
  556. 'assetManager' => [
  557. 'basePath' => __DIR__,
  558. 'baseUrl' => '',
  559. ],
  560. ];
  561. EOD;
  562. if (file_exists($configFile)) {
  563. if (!$this->confirm("File '{$configFile}' already exists. Do you wish to overwrite it?")) {
  564. return;
  565. }
  566. }
  567. if (!file_put_contents($configFile, $template)) {
  568. throw new Exception("Unable to write template file '{$configFile}'.");
  569. } else {
  570. echo "Configuration file template created at '{$configFile}'.\n\n";
  571. }
  572. }
  573. }