├── src ├── RemoveUnusedCssInterface.php ├── RemoveUnusedCss.php └── RemoveUnusedCssBasic.php ├── composer.json ├── LICENSE.md └── README.md /src/RemoveUnusedCssInterface.php: -------------------------------------------------------------------------------- 1 | whitelist('.fab', '.far', '.fal') 27 | ->styleSheets(public_path('**/*.css')) 28 | ->htmlFiles(resource_path('**/*.blade.php')) 29 | ->setFilenameSuffix('.refactored.min') 30 | ->minify() 31 | ->refactor() 32 | ->saveFiles(); 33 | ``` 34 | 35 | ## Classes 36 | 37 | The are two main ways of using the package: 38 | 39 | * Basic 40 | * Complete (In Development, not yet available) 41 | 42 | ### Basic Class 43 | 44 | The basic class is created using `RemoveUnusedCssBasic`. This is essentially a 'dumb' system that won't traverse the DOM in any way and will just include a selector if it's lowest level appears in the CSS. 45 | 46 | That said, this can still provide some significant savings in file size, especially when you're using a package like Bootstrap. 47 | 48 | ``` php 49 | $removeUnusedCss = new \Momentum81\PhpRemoveUnusedCss\RemoveUnusedCssBasic(); 50 | ``` 51 | 52 | The basic class only weakly matches, lets look at the following HTML: 53 | 54 | ```html 55 |
56 | Hello World 57 |
58 | ``` 59 | 60 | The following CSS Classes would match and be kept, despite the `.hello` class being used in the HTML not being inside a parent element using the class `.test`: 61 | 62 | ```css 63 | .test .hello {} 64 | .test .hello::after {} 65 | ``` 66 | 67 | ### Complete Class 68 | 69 | In Development. This method attempts to be smarter and where possible traverse the DOM as much as it can (When using a templating system this is infinitely more difficult if your views are not cached, so the system can only do so well here). 70 | 71 | 72 | ### Available Methods 73 | 74 | | Method | Description | 75 | | :--- | :--- | 76 | | `whitelist(...$selectors)` | Here you can provice multipleCSS selectors to whitelist, ensuring they remain in the CSS even if they are not present in the HTML. | 77 | | `styleSheets(...$styleSheets)` | Here you can provide glob compatible absolute paths to the stylesheets you want to refactor. For example in Laravel, you could use `public_path('**/*.css')`. | 78 | | `htmlFiles(...$htmlFiles)` | Here you can provide glob compatible absolute paths to the HTML files (Can be any text file type, not just `.html`) that you want to scan for selectors to keep in your refactored CSS. For example in Laravel, you could use `resource_path('**/*.blade.php')`. | 79 | | `setFilenameSuffix($string)` | By default the platform will overwrite the stylesheets it finds (When saving as files), here you can provide a suffix for the file name - this will get appended before the file type. So `stylesheet.css` could become `stylesheet.refactored.min.css`. The default value is `.refactored.min`. IMPORTANT: If you do not specify this method, your original files will be OVERWRITTEN if you use `saveFiles()`! | 80 | | `minify($bool)` | If specified, this will perform minification on the CSS before it's saved/returned, using the package `matthiasmullie/minify` (https://github.com/matthiasmullie/minify) | 81 | | `skipComment($bool)` | Specify if we should skip the comment at the top of the CSS file | 82 | | `comment($string)` | Specify custom text for the comment at the top of the CSS file - use the variable `:time:` for the date and time to be added in its place. Don't forget to add your opening and closing tags for the comment: `/* and */`. | 83 | | `refactor()` | This method performs the refactoring. | 84 | | `saveFiles()` | This will save the new (Or overwritten) files | 85 | | `returnAsText()` | This will return the refactored CSS as an array of files with text rather than writing them to real files. | 86 | -------------------------------------------------------------------------------- /src/RemoveUnusedCss.php: -------------------------------------------------------------------------------- 1 | minify = $bool; 50 | 51 | return $this; 52 | } 53 | 54 | 55 | /** 56 | * Specify if we should skip adding the comment to the file or not 57 | * 58 | * @param bool $bool 59 | * @return $this 60 | */ 61 | public function skipComment($bool = true) 62 | { 63 | $this->skipComment = $bool; 64 | 65 | return $this; 66 | } 67 | 68 | 69 | /** 70 | * Overwrite the CSS comment 71 | * 72 | * @param string $string 73 | * @return $this 74 | */ 75 | public function comment($string) 76 | { 77 | $this->cssFileComment = $string; 78 | 79 | return $this; 80 | } 81 | 82 | 83 | /** 84 | * Append the filenames of the CSS with this value (e.g. bootstrap.stripped.css) 85 | * 86 | * @param string $string 87 | * @return $this 88 | */ 89 | public function setFilenameSuffix($string = '.refactored.min') 90 | { 91 | $this->appendFilename = $string; 92 | 93 | return $this; 94 | } 95 | 96 | 97 | /** 98 | * Add items to the whitelist array 99 | * 100 | * @param string ...$selectors 101 | * @return $this 102 | */ 103 | public function whitelist(string ...$selectors) 104 | { 105 | foreach ($selectors as $whitelist) { 106 | 107 | if (!in_array($whitelist, $this->whitelistArray)) { 108 | $this->whitelistArray[] = $whitelist; 109 | } 110 | } 111 | 112 | return $this; 113 | } 114 | 115 | 116 | /** 117 | * Add items to the style sheets array 118 | * 119 | * @param string ...$styleSheets 120 | * @return $this 121 | */ 122 | public function styleSheets(string ...$styleSheets) 123 | { 124 | foreach ($styleSheets as $styleSheet) { 125 | 126 | if (!in_array($styleSheet, $this->styleSheetArray)) { 127 | $this->styleSheetArray[] = $styleSheet; 128 | } 129 | } 130 | 131 | return $this; 132 | } 133 | 134 | 135 | /** 136 | * Add items to the html files array 137 | * 138 | * @param string ...$htmlFiles 139 | * @return $this 140 | */ 141 | public function htmlFiles(string ...$htmlFiles) 142 | { 143 | foreach ($htmlFiles as $htmlFile) { 144 | 145 | if (!in_array($htmlFile, $this->htmlFileArray)) { 146 | $this->htmlFileArray[] = $htmlFile; 147 | } 148 | } 149 | 150 | return $this; 151 | } 152 | 153 | 154 | /** 155 | * Get the comment (if required) for the top 156 | * of the CSS 157 | * 158 | * @return string|string[] 159 | */ 160 | protected function getComment() 161 | { 162 | if ($this->skipComment) { 163 | return ''; 164 | } 165 | 166 | return str_replace(':time:', date('jS M Y, g:ia'), $this->cssFileComment); 167 | } 168 | 169 | 170 | /** 171 | * Find all the relevent HTML based files we want to scan 172 | * for used CSS elements 173 | * 174 | * @return void 175 | */ 176 | protected function findAllHtmlFiles() 177 | { 178 | foreach ($this->htmlFileArray as $searchPattern) { 179 | $this->foundHtmlFiles = array_merge($this->foundHtmlFiles, glob($searchPattern)); 180 | } 181 | } 182 | 183 | 184 | /** 185 | * Find all the relevent CSS files to scan 186 | * 187 | * @return void 188 | */ 189 | protected function findAllStyleSheetFiles() 190 | { 191 | foreach ($this->styleSheetArray as $searchPattern) { 192 | $this->foundCssFiles = array_merge($this->foundCssFiles, glob($searchPattern)); 193 | } 194 | 195 | if (!empty($this->appendFilename)) { 196 | 197 | foreach ($this->foundCssFiles as $key => $filename) { 198 | 199 | if (strpos($filename, $this->appendFilename) !== false) { 200 | unset($this->foundCssFiles[$key]); 201 | } 202 | } 203 | } 204 | } 205 | 206 | 207 | /*** 208 | * Create and fill a new file 209 | * 210 | * @param string $filename 211 | * @param stirng $source 212 | */ 213 | protected function createFile($filename, $source) 214 | { 215 | touch($filename); 216 | file_put_contents($filename, $source); 217 | } 218 | 219 | 220 | /** 221 | * Minify some CSS 222 | * 223 | * @param string $string 224 | * @return string 225 | */ 226 | protected function performMinification($string) 227 | { 228 | $minifier = new CSS(); 229 | $minifier->add($string); 230 | 231 | return $this->getComment().$minifier->minify(); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/RemoveUnusedCssBasic.php: -------------------------------------------------------------------------------- 1 | , regardless if it is wrapped in 16 | * another like
17 | */ 18 | class RemoveUnusedCssBasic implements RemoveUnusedCssInterface 19 | { 20 | /** 21 | * Traits 22 | */ 23 | use RemoveUnusedCss; 24 | 25 | 26 | /** 27 | * @var array 28 | */ 29 | protected $foundUsedCssElements = ['*']; 30 | protected $foundCssStructure = []; 31 | protected $readyForSave = []; 32 | 33 | 34 | /** 35 | * @var string 36 | */ 37 | protected $elementForNoMediaBreak = '__NO_MEDIA__'; 38 | 39 | 40 | /** 41 | * @var array 42 | */ 43 | protected $regexForHtmlFiles = [ 44 | 'HTML Tags' => [ 45 | 'regex' => '/\<([[:alnum:]_-]+).*(?!\/)\>/', 46 | 'stringPlaceBefore' => '', 47 | 'stringPlaceAfter' => '', 48 | ], 49 | 'CSS Classes' => [ 50 | 'regex' => '/\<.*class\=\"([[:alnum:]\s_-]+)\".*(?!\/)\>/', 51 | 'stringPlaceBefore' => '.', 52 | 'stringPlaceAfter' => '', 53 | ], 54 | 'IDs' => [ 55 | 'regex' => '/\<.*id\=\"([[:alnum:]\s_-]+)\".*(?!\/)\>/', 56 | 'stringPlaceBefore' => '#', 57 | 'stringPlaceAfter' => '', 58 | ], 59 | 'Data Tags (Without Values)' => [ 60 | 'regex' => '/\<.*(data-[[:alnum:]_-]+)\=\"(.*)\".*(?!\/)\>/', 61 | 'stringPlaceBefore' => '[', 62 | 'stringPlaceAfter' => ']', 63 | ], 64 | 'Data Tags (With Values)' => [ 65 | 'regex' => '/\<.*(data-[[:alnum:]_-]+\=\"(.*)\").*(?!\/)\>/', 66 | 'stringPlaceBefore' => '[', 67 | 'stringPlaceAfter' => ']', 68 | ], 69 | ]; 70 | 71 | 72 | /** 73 | * @var string[] 74 | */ 75 | protected $regexForCssFiles = [ 76 | '/}*([\[*a-zA-Z0-9-_ \~\>\^\"\=\n\(\)\@\+\,\.\#\:\]*]+){+([^}]+)}/', 77 | ]; 78 | 79 | 80 | /** 81 | * @inheritDoc 82 | */ 83 | public function refactor() 84 | { 85 | $this->findAllHtmlFiles(); 86 | $this->findAllStyleSheetFiles(); 87 | $this->scanHtmlFilesForUsedElements(); 88 | $this->scanCssFilesForAllElements(); 89 | $this->filterCss(); 90 | $this->prepareForSaving(); 91 | 92 | return $this; 93 | } 94 | 95 | 96 | /** 97 | * @inheritDoc 98 | */ 99 | public function saveFiles() { 100 | 101 | $this->createFiles(); 102 | 103 | return $this; 104 | } 105 | 106 | 107 | /** 108 | * @inheritDoc 109 | */ 110 | public function returnAsText(): array 111 | { 112 | return $this->readyForSave; 113 | } 114 | 115 | 116 | /** 117 | * Strip out the unused element 118 | * 119 | * @return void 120 | */ 121 | protected function filterCss() 122 | { 123 | foreach ($this->foundCssStructure as $file => &$fileData) { 124 | 125 | foreach ($fileData as $key => &$block) { 126 | 127 | foreach ($block as $selectors => $values) { 128 | 129 | $keep = false; 130 | $mergedArray = array_merge($this->whitelistArray, $this->foundUsedCssElements); 131 | 132 | foreach (explode(',', $selectors) as $selector) { 133 | 134 | $explodeA = explode(' ', $selector); 135 | $explodeB = explode(' ', explode(':', $selector)[0]); 136 | $explodeC = explode(':', $selector)[0]; 137 | 138 | if ( 139 | (in_array(end($explodeA), $mergedArray)) || 140 | (in_array(end($explodeB), $mergedArray)) || 141 | (in_array($explodeC, $mergedArray)) 142 | ) { 143 | $keep = true; 144 | } 145 | } 146 | 147 | if (!$keep) { 148 | unset($block[$selectors]); 149 | } 150 | } 151 | } 152 | } 153 | } 154 | 155 | 156 | /** 157 | * Get the source ready to be saved in files or returned 158 | * 159 | * @return void 160 | */ 161 | public function prepareForSaving() 162 | { 163 | foreach ($this->foundCssStructure as $file => $fileData) { 164 | 165 | $source = ''; 166 | 167 | foreach ($fileData as $key => $block) { 168 | 169 | $prefix = ''; 170 | $postfix = ''; 171 | $indent = 0; 172 | 173 | if ($key != $this->elementForNoMediaBreak) { 174 | 175 | $prefix = $key." {\n"; 176 | $postfix = "}\n\n"; 177 | $indent = 4; 178 | } 179 | 180 | if (!empty($block)) { 181 | 182 | $source .= $prefix; 183 | 184 | foreach ($block as $selector => $values) { 185 | 186 | $values = trim($values); 187 | 188 | if (substr($values, -1) !== ';') { $values .= ';'; } 189 | if (strpos($values, '{') !== false) { $values .= '}'; } 190 | 191 | $source .= str_pad('', $indent, ' ').$selector." {\n"; 192 | $source .= str_pad('', $indent, ' ')." ".$values."\n"; 193 | $source .= str_pad('', $indent, ' ')."}\n"; 194 | } 195 | 196 | $source .= $postfix; 197 | } 198 | } 199 | 200 | $filenameBeforeExt = substr($file, 0, strrpos($file, '.')); 201 | $filenameExt = substr($file, strrpos($file, '.'), strlen($file)); 202 | 203 | if (!empty($this->appendFilename)) { 204 | $filenameExt = $this->appendFilename.$filenameExt; 205 | } 206 | 207 | $newFileName = $filenameBeforeExt.$filenameExt; 208 | 209 | $this->readyForSave[] = [ 210 | 'filename' => $file, 211 | 'newFilename' => $newFileName, 212 | 'source' => ( 213 | $this->minify 214 | ? $this->performMinification($source) 215 | : $this->getComment().$source 216 | ), 217 | ]; 218 | } 219 | } 220 | 221 | 222 | /** 223 | * Create the stripped down CSS files 224 | * 225 | * @return void 226 | */ 227 | protected function createFiles() 228 | { 229 | foreach ($this->readyForSave as $fileData) { 230 | $this->createFile($fileData['newFilename'], $fileData['source']); 231 | } 232 | } 233 | 234 | 235 | /** 236 | * Scan the CSS files for all main elements 237 | * 238 | * @return void 239 | */ 240 | protected function scanCssFilesForAllElements() 241 | { 242 | foreach ($this->foundCssFiles as $file) { 243 | 244 | $breaks = explode('@media', file_get_contents($file)); 245 | 246 | $loop = 0; 247 | 248 | foreach ($breaks as $break) { 249 | 250 | $break = trim($break); 251 | 252 | if ($loop == 0) { 253 | $key = $this->elementForNoMediaBreak; 254 | $cssSectionOfBreakArray = [$break]; 255 | } else { 256 | $key = '@media '.substr($break, 0, strpos($break, '{')); 257 | $cssSectionOfBreakToArrayize = substr($break, strpos($break, '{'), strrpos($break, '}')); 258 | $cssSectionOfBreakArray = $this->splitBlockIntoMultiple($cssSectionOfBreakToArrayize); 259 | } 260 | 261 | foreach ($cssSectionOfBreakArray as $counter => $cssSectionOfBreak) { 262 | 263 | if ($counter > 0) { 264 | $key = $this->elementForNoMediaBreak; 265 | } 266 | 267 | foreach ($this->regexForCssFiles as $regex) { 268 | 269 | preg_match_all($regex, $cssSectionOfBreak, $matches, PREG_PATTERN_ORDER); 270 | 271 | if (!empty($matches)) { 272 | 273 | foreach ($matches[1] as $regexKey => $element) { 274 | $this->foundCssStructure[$file][$key][trim(preg_replace('/\s+/', ' ', $element))] = trim(preg_replace('/\s+/', ' ', $matches[2][$regexKey])); 275 | } 276 | } 277 | } 278 | } 279 | 280 | $loop++; 281 | } 282 | } 283 | } 284 | 285 | 286 | /** 287 | * Because we break on @media there's often another block of non 288 | * @media CSS after it, so we need to get that out separately 289 | * 290 | * @param string $string 291 | * @return string[] 292 | */ 293 | protected function splitBlockIntoMultiple($string = '') 294 | { 295 | $totalOpen = 0; 296 | $totalClosed = 0; 297 | $counterMark = 0; 298 | $blocks = []; 299 | $stringSoFar = ''; 300 | 301 | foreach (str_split($string) as $counter => $character) { 302 | 303 | $stringSoFar .= $character; 304 | 305 | if ($character == '{') { 306 | $totalOpen++; 307 | } 308 | 309 | if ($character == '}') { 310 | 311 | $totalClosed++; 312 | 313 | if ($totalClosed == $totalOpen) { 314 | 315 | $blocks[$counterMark] = $stringSoFar; 316 | 317 | $stringSoFar = ''; $totalOpen = 0; $totalClosed = 0; 318 | $counterMark = $counter; 319 | } 320 | } 321 | } 322 | 323 | $returnBlock = [0 => '', 1 => '']; 324 | 325 | foreach ($blocks as $block) { 326 | 327 | if (substr(trim($block), 0, 1) == '{') { 328 | $returnBlock[0] = $block; 329 | } else { 330 | $returnBlock[1] .= $block."\n"; 331 | } 332 | } 333 | 334 | return array_filter($returnBlock); 335 | } 336 | 337 | 338 | /** 339 | * Find all matching HTML css elements 340 | * 341 | * @return void 342 | */ 343 | protected function scanHtmlFilesForUsedElements() 344 | { 345 | foreach ($this->foundHtmlFiles as $file) { 346 | 347 | foreach ($this->regexForHtmlFiles as $regex) { 348 | 349 | preg_match_all($regex['regex'], file_get_contents($file), $matches, PREG_PATTERN_ORDER); 350 | 351 | if (isset($matches[1])) { 352 | 353 | foreach ($matches[1] as $match) { 354 | 355 | foreach (explode(' ', $match) as $explodedMatch) { 356 | 357 | $formattedMatch = $regex['stringPlaceBefore'].trim($explodedMatch).$regex['stringPlaceAfter']; 358 | 359 | if (!in_array($formattedMatch, $this->foundUsedCssElements)) { 360 | $this->foundUsedCssElements[] = $formattedMatch; 361 | } 362 | } 363 | } 364 | } 365 | } 366 | } 367 | } 368 | } 369 | --------------------------------------------------------------------------------