-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
b02ba58
commit 0365863
Showing
2 changed files
with
324 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace DanBettles\Marigold; | ||
|
||
use RuntimeException; | ||
|
||
use function preg_replace; | ||
use function str_replace; | ||
use function trim; | ||
|
||
use const null; | ||
|
||
/** | ||
* This is a very lightweight, but effective, CSS minifier derived from https://github.com/danbettles/yacm. It is | ||
* slightly different from other minifiers in that it preserves the approximate vertical structure of the original CSS. | ||
* So unless the formatting of the input is very unusual, the output should be just about readable. | ||
*/ | ||
class CssMinifier | ||
{ | ||
/** | ||
* @throws RuntimeException If it failed to perform the replacement. | ||
*/ | ||
private function replace( | ||
string $pattern, | ||
string $replacement, | ||
string $subject | ||
): string { | ||
$result = preg_replace($pattern, $replacement, $subject); | ||
|
||
if (null === $result) { | ||
throw new RuntimeException("Failed to replace `{$pattern}` with `{$replacement}`."); | ||
} | ||
|
||
return $result; | ||
} | ||
|
||
/** | ||
* Removes superfluous whitespace from the specified CSS; enough newlines are left, however, to preserve the basic | ||
* vertical structure of the CSS, to make it just about readable after minification. | ||
*/ | ||
public function removeSuperfluousWhitespaceFilter(string $css): string | ||
{ | ||
//Normalize newlines. | ||
$css = str_replace(["\r\n", "\r", "\n"], "\n", $css); | ||
|
||
//Normalize horizontal whitespace. | ||
$css = str_replace("\t", ' ', $css); | ||
|
||
//Replace multiple, contiguous occurrences of the same whitespace character with just one. | ||
//We don't simply replace all whitespace characters with spaces - for example - because we want to retain the | ||
//vertical structure of the CSS, so that it's just about readable after minification. | ||
$css = $this->replace('/([\n ])\1+/', '$1', $css); | ||
|
||
//Remove horizontal whitespace from around delimiters. | ||
$css = $this->replace('/[ ]*([,:;\{\}])[ ]*/', '$1', $css); | ||
|
||
//Remove leading and trailing whitespace from lines. | ||
$css = $this->replace('/^[ ]*(.*?)[ ]*$/m', '$1', $css); | ||
|
||
//Remove empty lines. | ||
$css = trim($this->replace('/(?<=\n)[ ]*\n|/', '', $css)); | ||
|
||
return $css; | ||
} | ||
|
||
/** | ||
* Removes comments from the specified CSS. | ||
*/ | ||
public function removeCommentsFilter(string $css): string | ||
{ | ||
return $this->replace('~\/\*(.*?)\*\/~s', '', $css); | ||
} | ||
|
||
/** | ||
* Removes units from zero values. | ||
* | ||
* A zero value with units, no matter what unit of measurement is used, always equates to zero ("0"), so the units | ||
* are a waste of space. | ||
*/ | ||
public function removeUnitsFromZeroesFilter(string $css): string | ||
{ | ||
//See http://www.w3.org/TR/CSS21/grammar.html#scanner and http://www.w3schools.com/cssref/css_units.asp | ||
return $this->replace('/\b0((?:em|ex|ch|rem|vw|vh|vmin|vm|vmax|cm|mm|in|px|pt|pc)\b|%)/i', '0', $css); | ||
} | ||
|
||
/** | ||
* If possible, replaces hex colours with their condensed equivalents. | ||
*/ | ||
public function condenseHexColoursFilter(string $css): string | ||
{ | ||
$hexByte = '[\da-fA-F]'; | ||
|
||
return $this->replace("/#({$hexByte})\\1({$hexByte})\\2({$hexByte})\\3/", '#$1$2$3', $css); | ||
} | ||
|
||
/** | ||
* Minifies the specified CSS. | ||
*/ | ||
public function minify(string $css): string | ||
{ | ||
$css = $this->removeCommentsFilter($css); | ||
$css = $this->removeUnitsFromZeroesFilter($css); | ||
$css = $this->condenseHexColoursFilter($css); | ||
$css = $this->removeSuperfluousWhitespaceFilter($css); | ||
|
||
return $css; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,214 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace DanBettles\Marigold\Tests; | ||
|
||
use DanBettles\Marigold\AbstractTestCase; | ||
use DanBettles\Marigold\CssMinifier; | ||
|
||
class CssMinifierTest extends AbstractTestCase | ||
{ | ||
/** @return array<int, array<int, mixed>> */ | ||
public function providesCssWithCommentsRemoved(): array | ||
{ | ||
return [ | ||
[ | ||
'', | ||
'', | ||
], | ||
[ | ||
'', | ||
'/*Foo*/', | ||
], | ||
[ | ||
"\nbody {\n font-family: sans-serif;\n}", | ||
"/*Foo*/\nbody {\n font-family: sans-serif;\n}", | ||
], | ||
[ | ||
"body {\n \n font-family: sans-serif;\n}", | ||
"body {\n /*Foo*/\n font-family: sans-serif;\n}", | ||
], | ||
[ | ||
"body {\n font-family: sans-serif; \n}", | ||
"body {\n font-family: sans-serif; /*Foo*/\n}", | ||
], | ||
[ | ||
'', | ||
'/**#@+Something*//**#@-Something*/', | ||
], | ||
]; | ||
} | ||
|
||
/** @dataProvider providesCssWithCommentsRemoved */ | ||
public function testRemovecommentsfilterRemovesComments( | ||
string $expected, | ||
string $css | ||
): void { | ||
$minified = (new CssMinifier())->removeCommentsFilter($css); | ||
|
||
$this->assertSame($expected, $minified); | ||
} | ||
|
||
/** @return array<int, array<int, mixed>> */ | ||
public function providesCssWithSuperfluousWhitespaceRemoved(): array | ||
{ | ||
return [ | ||
[ | ||
'', | ||
'', | ||
], | ||
[ | ||
"body{\nfont-family:sans-serif;\nfont-size:1em;\ncolor:#000;\n}", | ||
"body {\r\nfont-family: sans-serif;\rfont-size: 1em;\ncolor: #000;\n}", | ||
], | ||
[ | ||
"body{\nfont-family:sans-serif;\nfont-size:1em;\ncolor:#000;\n}", | ||
"body {\n font-family: sans-serif;\n font-size: 1em;\n color: #000;\n}", | ||
], | ||
[ | ||
"body{\nfont-family:sans-serif;\nfont-size:1em;\ncolor:#000;\n}", | ||
"body {\n\tfont-family: sans-serif;\n\tfont-size: 1em;\n\tcolor: #000;\n}", | ||
], | ||
[ | ||
'body{font-family:sans-serif;}', | ||
' body {font-family: sans-serif;} ', | ||
], | ||
[ | ||
"body{\nfont-family:sans-serif;\n}", | ||
"body {\n font-family: sans-serif;\n}", | ||
], | ||
[ | ||
"body{\nfont-family:sans-serif;\n}\np{\nline-height:1em;\n}", | ||
"body {\n font-family: sans-serif;\n}\n\np {\n line-height: 1em;\n}", | ||
], | ||
[ | ||
'body{font-family:sans-serif;}', | ||
"body {font-family: sans-serif;}\n", | ||
], | ||
[ | ||
"body{\nfont-family:sans-serif;\n}\np{\nline-height:1em;\n}", | ||
"body {\n font-family: sans-serif;\n}\n \np {\n line-height: 1em;\n}", | ||
], | ||
[ | ||
"h1,h2{\nfont-weight:bold;\n}", | ||
"h1, h2 {\n font-weight: bold;\n}", | ||
], | ||
[ | ||
"h1,h2{font-weight:bold;}", | ||
"h1 , h2 { font-weight : bold ; }", | ||
], | ||
]; | ||
} | ||
|
||
/** @dataProvider providesCssWithSuperfluousWhitespaceRemoved */ | ||
public function testRemovesuperfluouswhitespacefilterRemovesSuperfluousWhitespace( | ||
string $expected, | ||
string $css | ||
): void { | ||
$minified = (new CssMinifier())->removeSuperfluousWhitespaceFilter($css); | ||
|
||
$this->assertSame($expected, $minified); | ||
} | ||
|
||
/** @return array<int, array<int, mixed>> */ | ||
public function providesCssContainingZeroesWithUnitsRemoved(): array | ||
{ | ||
return [ | ||
[ | ||
'', | ||
'', | ||
], | ||
[ | ||
'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0', | ||
'0em 0ex 0ch 0rem 0vw 0vh 0vmin 0vm 0vmax 0% 0cm 0mm 0in 0px 0pt 0pc', | ||
], | ||
[ | ||
'.smallest { font-size: 0; }', | ||
'.smallest { font-size: 0em; }', | ||
], | ||
]; | ||
} | ||
|
||
/** @dataProvider providesCssContainingZeroesWithUnitsRemoved */ | ||
public function testRemoveunitsfromzeroesfilterRemovesUnitsFromZeroes( | ||
string $expected, | ||
string $css | ||
): void { | ||
$minified = (new CssMinifier())->removeUnitsFromZeroesFilter($css); | ||
|
||
$this->assertSame($expected, $minified); | ||
} | ||
|
||
/** @return array<int, array<int, mixed>> */ | ||
public function providesCssContainingHexColoursThatHaveBeenCondensed(): array | ||
{ | ||
return [ | ||
[ | ||
'', | ||
'', | ||
], | ||
[ | ||
'#fff #abc #123456', | ||
'#ffffff #aabbcc #123456', | ||
], | ||
[ | ||
'body { color: #000; }', | ||
'body { color: #000000; }', | ||
], | ||
]; | ||
} | ||
|
||
/** @dataProvider providesCssContainingHexColoursThatHaveBeenCondensed */ | ||
public function testCondensehexcoloursfilterCondensesHexColours( | ||
string $expected, | ||
string $css | ||
): void { | ||
$minified = (new CssMinifier())->condenseHexColoursFilter($css); | ||
|
||
$this->assertSame($expected, $minified); | ||
} | ||
|
||
/** @return array<int, array<int, mixed>> */ | ||
public function providesCssThatHasBeenMinified(): array | ||
{ | ||
return [ | ||
[ | ||
'', | ||
'', | ||
], | ||
[ | ||
<<<END | ||
body{ | ||
color:#000; | ||
} | ||
h1,h2{font-weight:bold;} | ||
.smallest{ | ||
font-size:0; | ||
} | ||
END, | ||
<<<END | ||
body { | ||
color : #000000 ; /*Colour value will be condensed.*/ | ||
} | ||
/*This empty line will be removed.*/ | ||
h1, h2 { font-weight: bold; } | ||
.smallest { | ||
font-size: 0em; | ||
} | ||
END, | ||
], | ||
]; | ||
} | ||
|
||
/** @dataProvider providesCssThatHasBeenMinified */ | ||
public function testMinifyMinifiesTheSpecifiedCssUsingAllFilters( | ||
string $expected, | ||
string $css | ||
): void { | ||
$minified = (new CssMinifier())->minify($css); | ||
|
||
$this->assertSame($expected, $minified); | ||
} | ||
} |