Pipeline.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. <?php
  2. /**
  3. * @package Grav\Common\Assets
  4. *
  5. * @copyright Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\Assets;
  9. use Grav\Common\Assets\Traits\AssetUtilsTrait;
  10. use Grav\Common\Config\Config;
  11. use Grav\Common\Filesystem\Folder;
  12. use Grav\Common\Grav;
  13. use Grav\Common\Uri;
  14. use Grav\Common\Utils;
  15. use Grav\Framework\Object\PropertyObject;
  16. use MatthiasMullie\Minify\CSS;
  17. use MatthiasMullie\Minify\JS;
  18. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  19. use function array_key_exists;
  20. /**
  21. * Class Pipeline
  22. * @package Grav\Common\Assets
  23. */
  24. class Pipeline extends PropertyObject
  25. {
  26. use AssetUtilsTrait;
  27. protected const CSS_ASSET = 1;
  28. protected const JS_ASSET = 2;
  29. protected const JS_MODULE_ASSET = 3;
  30. /** @const Regex to match CSS urls */
  31. protected const CSS_URL_REGEX = '{url\(([\'\"]?)(.*?)\1\)}';
  32. /** @const Regex to match JS imports */
  33. protected const JS_IMPORT_REGEX = '{import.+from\s?[\'|\"](.+?)[\'|\"]}';
  34. /** @const Regex to match CSS sourcemap comments */
  35. protected const CSS_SOURCEMAP_REGEX = '{\/\*# (.*?) \*\/}';
  36. protected const FIRST_FORWARDSLASH_REGEX = '{^\/{1}\w}';
  37. // Following variables come from the configuration:
  38. /** @var bool */
  39. protected $css_minify = false;
  40. /** @var bool */
  41. protected $css_minify_windows = false;
  42. /** @var bool */
  43. protected $css_rewrite = false;
  44. /** @var bool */
  45. protected $css_pipeline_include_externals = true;
  46. /** @var bool */
  47. protected $js_minify = false;
  48. /** @var bool */
  49. protected $js_minify_windows = false;
  50. /** @var bool */
  51. protected $js_pipeline_include_externals = true;
  52. /** @var string */
  53. protected $assets_dir;
  54. /** @var string */
  55. protected $assets_url;
  56. /** @var string */
  57. protected $timestamp;
  58. /** @var array */
  59. protected $attributes;
  60. /** @var string */
  61. protected $query = '';
  62. /** @var string */
  63. protected $asset;
  64. /**
  65. * Pipeline constructor.
  66. * @param array $elements
  67. * @param string|null $key
  68. */
  69. public function __construct(array $elements = [], ?string $key = null)
  70. {
  71. parent::__construct($elements, $key);
  72. /** @var UniformResourceLocator $locator */
  73. $locator = Grav::instance()['locator'];
  74. /** @var Config $config */
  75. $config = Grav::instance()['config'];
  76. /** @var Uri $uri */
  77. $uri = Grav::instance()['uri'];
  78. $this->base_url = rtrim($uri->rootUrl($config->get('system.absolute_urls')), '/') . '/';
  79. $this->assets_dir = $locator->findResource('asset://');
  80. if (!$this->assets_dir) {
  81. // Attempt to create assets folder if it doesn't exist yet.
  82. $this->assets_dir = $locator->findResource('asset://', true, true);
  83. Folder::mkdir($this->assets_dir);
  84. $locator->clearCache();
  85. }
  86. $this->assets_url = $locator->findResource('asset://', false);
  87. }
  88. /**
  89. * Minify and concatenate CSS
  90. *
  91. * @param array $assets
  92. * @param string $group
  93. * @param array $attributes
  94. * @return bool|string URL or generated content if available, else false
  95. */
  96. public function renderCss($assets, $group, $attributes = [])
  97. {
  98. // temporary list of assets to pipeline
  99. $inline_group = false;
  100. if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') {
  101. $inline_group = true;
  102. unset($attributes['loading']);
  103. }
  104. // Store Attributes
  105. $this->attributes = array_merge(['type' => 'text/css', 'rel' => 'stylesheet'], $attributes);
  106. // Compute uid based on assets and timestamp
  107. $json_assets = json_encode($assets);
  108. $uid = md5($json_assets . (int)$this->css_minify . (int)$this->css_rewrite . $group);
  109. $file = $uid . '.css';
  110. $relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
  111. $filepath = "{$this->assets_dir}/{$file}";
  112. if (file_exists($filepath)) {
  113. $buffer = file_get_contents($filepath) . "\n";
  114. } else {
  115. //if nothing found get out of here!
  116. if (empty($assets)) {
  117. return false;
  118. }
  119. // Concatenate files
  120. $buffer = $this->gatherLinks($assets, self::CSS_ASSET);
  121. // Minify if required
  122. if ($this->shouldMinify('css')) {
  123. $minifier = new CSS();
  124. $minifier->add($buffer);
  125. $buffer = $minifier->minify();
  126. }
  127. // Write file
  128. if (trim($buffer) !== '') {
  129. file_put_contents($filepath, $buffer);
  130. }
  131. }
  132. if ($inline_group) {
  133. $output = "<style>\n" . $buffer . "\n</style>\n";
  134. } else {
  135. $this->asset = $relative_path;
  136. $output = '<link href="' . $relative_path . $this->renderQueryString() . '"' . $this->renderAttributes() . BaseAsset::integrityHash($this->asset) . ">\n";
  137. }
  138. return $output;
  139. }
  140. /**
  141. * Minify and concatenate JS files.
  142. *
  143. * @param array $assets
  144. * @param string $group
  145. * @param array $attributes
  146. * @return bool|string URL or generated content if available, else false
  147. */
  148. public function renderJs($assets, $group, $attributes = [], $type = self::JS_ASSET)
  149. {
  150. // temporary list of assets to pipeline
  151. $inline_group = false;
  152. if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') {
  153. $inline_group = true;
  154. unset($attributes['loading']);
  155. }
  156. // Store Attributes
  157. $this->attributes = $attributes;
  158. // Compute uid based on assets and timestamp
  159. $json_assets = json_encode($assets);
  160. $uid = md5($json_assets . $this->js_minify . $group);
  161. $file = $uid . '.js';
  162. $relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
  163. $filepath = "{$this->assets_dir}/{$file}";
  164. if (file_exists($filepath)) {
  165. $buffer = file_get_contents($filepath) . "\n";
  166. } else {
  167. //if nothing found get out of here!
  168. if (empty($assets)) {
  169. return false;
  170. }
  171. // Concatenate files
  172. $buffer = $this->gatherLinks($assets, $type);
  173. // Minify if required
  174. if ($this->shouldMinify('js')) {
  175. $minifier = new JS();
  176. $minifier->add($buffer);
  177. $buffer = $minifier->minify();
  178. }
  179. // Write file
  180. if (trim($buffer) !== '') {
  181. file_put_contents($filepath, $buffer);
  182. }
  183. }
  184. if ($inline_group) {
  185. $output = '<script' . $this->renderAttributes(). ">\n" . $buffer . "\n</script>\n";
  186. } else {
  187. $this->asset = $relative_path;
  188. $output = '<script src="' . $relative_path . $this->renderQueryString() . '"' . $this->renderAttributes() . BaseAsset::integrityHash($this->asset) . "></script>\n";
  189. }
  190. return $output;
  191. }
  192. /**
  193. * Minify and concatenate JS files.
  194. *
  195. * @param array $assets
  196. * @param string $group
  197. * @param array $attributes
  198. * @return bool|string URL or generated content if available, else false
  199. */
  200. public function renderJs_Module($assets, $group, $attributes = [])
  201. {
  202. $attributes['type'] = 'module';
  203. return $this->renderJs($assets, $group, $attributes, self::JS_MODULE_ASSET);
  204. }
  205. /**
  206. * Finds relative CSS urls() and rewrites the URL with an absolute one
  207. *
  208. * @param string $file the css source file
  209. * @param string $dir , $local relative path to the css file
  210. * @param bool $local is this a local or remote asset
  211. * @return string
  212. */
  213. protected function cssRewrite($file, $dir, $local)
  214. {
  215. // Strip any sourcemap comments
  216. $file = preg_replace(self::CSS_SOURCEMAP_REGEX, '', $file);
  217. // Find any css url() elements, grab the URLs and calculate an absolute path
  218. // Then replace the old url with the new one
  219. $file = (string)preg_replace_callback(self::CSS_URL_REGEX, function ($matches) use ($dir, $local) {
  220. $old_url = $matches[2];
  221. // Ensure link is not rooted to web server, a data URL, or to a remote host
  222. if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url) || Utils::startsWith($old_url, 'data:') || $this->isRemoteLink($old_url)) {
  223. return $matches[0];
  224. }
  225. // clean leading /
  226. $old_url = Utils::normalizePath($dir . '/' . $old_url);
  227. if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) {
  228. $old_url = ltrim($old_url, '/');
  229. }
  230. $new_url = ($local ? $this->base_url : '') . $old_url;
  231. return str_replace($matches[2], $new_url, $matches[0]);
  232. }, $file);
  233. return $file;
  234. }
  235. /**
  236. * Finds relative JS urls() and rewrites the URL with an absolute one
  237. *
  238. * @param string $file the css source file
  239. * @param string $dir local relative path to the css file
  240. * @param bool $local is this a local or remote asset
  241. * @return string
  242. */
  243. protected function jsRewrite($file, $dir, $local)
  244. {
  245. // Find any js import elements, grab the URLs and calculate an absolute path
  246. // Then replace the old url with the new one
  247. $file = (string)preg_replace_callback(self::JS_IMPORT_REGEX, function ($matches) use ($dir, $local) {
  248. $old_url = $matches[1];
  249. // Ensure link is not rooted to web server, a data URL, or to a remote host
  250. if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url) || $this->isRemoteLink($old_url)) {
  251. return $matches[0];
  252. }
  253. // clean leading /
  254. $old_url = Utils::normalizePath($dir . '/' . $old_url);
  255. $old_url = str_replace('/./', '/', $old_url);
  256. if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) {
  257. $old_url = ltrim($old_url, '/');
  258. }
  259. $new_url = ($local ? $this->base_url : '') . $old_url;
  260. return str_replace($matches[1], $new_url, $matches[0]);
  261. }, $file);
  262. return $file;
  263. }
  264. /**
  265. * @param string $type
  266. * @return bool
  267. */
  268. private function shouldMinify($type = 'css')
  269. {
  270. $check = $type . '_minify';
  271. $win_check = $type . '_minify_windows';
  272. $minify = (bool) $this->$check;
  273. // If this is a Windows server, and minify_windows is false (default value) skip the
  274. // minification process because it will cause Apache to die/crash due to insufficient
  275. // ThreadStackSize in httpd.conf - See: https://bugs.php.net/bug.php?id=47689
  276. if (stripos(php_uname('s'), 'WIN') === 0 && !$this->{$win_check}) {
  277. $minify = false;
  278. }
  279. return $minify;
  280. }
  281. }