Skip to content

Commit

Permalink
Added YACM CSS minifier.
Browse files Browse the repository at this point in the history
  • Loading branch information
danbettles committed Sep 18, 2022
1 parent b02ba58 commit 0365863
Show file tree
Hide file tree
Showing 2 changed files with 324 additions and 0 deletions.
110 changes: 110 additions & 0 deletions src/CssMinifier.php
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;
}
}
214 changes: 214 additions & 0 deletions tests/src/CssMinifierTest.php
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);
}
}

0 comments on commit 0365863

Please sign in to comment.