├── 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 |
--------------------------------------------------------------------------------