22 May 2020

PHP: Easily combine or bundle CSS & JavaScript files

As with all topics related to software development, there is always more than one solution to a problem. The following proposition is simply one possible solution and not intended to be definitive.

Summary:

  • Combine/Bundle CSS and JavaScript files into single files.
  • Minify CSS and Minify JavaScript.
  • Add Asset Packer to you PHP templates.

Sometimes you need a simple solution but the world of software engineering and web development loves to over-complicate everything. And, as developers, instinctively we feel more comfortable that we're doing a good job if our code is highly configurable, extensible, testable and feature rich. Sounds about perfect to me, too!

However... Often you just want to get a job done quickly, easily and without any significant overhead.

Anyone who has had exposure to Google's PageSpeed Insights has no-doubt experienced the following messages:

  • Minify JavaScript
    Minifying JavaScript files can reduce payload sizes and script parse time.
  • Minify CSS
    Minifying CSS files can reduce network payload sizes.
  • Keep request counts low and transfer sizes small

Essentially, what PageSpeed is [partially] telling you to do is combine (aka bundle) all those render blocking, individual .js and .css files into single, minified .css and .js files.

A web search will yield an enormous list of JavaScript and StyleSheet minifiers, compressors, bundlers and packagers. The top-tier tools are incredibly efficient, reliable and well tested... Also, quite complicated and offer a 100+ features that you probably don't yet need.

You just want to minify and combine your files, darn-it!!!! You want to avoid installing multiple command line tools and configuring a comprehensive publishing pipeline for your simple, little PHP website.

In my opinion, a neat, on-the-fly, pattern for Asset Packaging looks like this:

<html>
    <head>
     ...
    <?php 
        echo AssetPacker::css('/Assets/css/my-bundled-css-1-0-0.css',
            [
                '/Assets/css/bootstrap.min.css',
                '/Assets/css/lightbox.min.css',
                '/Assets/css/custom.css',
                '/Assets/css/updates-feb2020.css'
            ]);
    ?>
    </head>
    <body>
    ....
    <?php 
        echo AssetPacker::js('/Assets/js/my-bundled-js-1-0-0.js',
            [
                '/Assets/js/jquery.min.js',
                '/Assets/js/bootstrap.min.js',
                '/Assets/js/lightbox.min.js',
                '/Assets/js/custom.js',
                '/Assets/js/other-guys-code.js'
            ]);
    ?>
    </body>
</html>

When a page renders, the outcome of this pattern would look something like this:

<html>
    <head>
     ...
        <link rel="stylesheet" href="/Assets/css/my-bundled-css-1-0-0.css">	
    </head>
    <body>
    ....
    <script src="/Assets/js/my-bundled-js-1-0-0.js" defer></script>
</html>

Too easy, right? Fortunately, creating an "AssetPacker" class isn't as complicated as it may initially appear.

Firstly, you need to use PHP to read the files on your server, load them into memory and then write them sequentially into a new bundle file on the server.

Secondly, during the bundling process (and this is, admittedly, crude) check the asset's file name and if it doesn't include the universal ".min." tag, minify the file using one of the myriad of open-source minify algorithms available on github.

And finally, return the HTML stylesheet or script tag linking to the new bundle file.

Without getting bogged-down in the details, here is a basic version of the AssetPacker to use as a case study:

<?php
namespace PurcellYoon\Demo;

class AssetPacker
{
	const SERVER_ROOT_PATH = '/home/wwww/public_html';

	// Stylesheet (CSS) Minifier
	// Credits: https://gist.github.com/Rodrigo54/93169db48194d470188f, https://github.com/mecha-cms/extend.minify
	public static function minifyStyles( $input ) 
	{
		if(trim($input) === "") 
			return $input;
		
		return preg_replace(
			array(
				// Remove comment(s)
				'#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')|\/\*(?!\!)(?>.*?\*\/)|^\s*|\s*$#s',
				// Remove unused white-space(s)
				'#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\'|\/\*(?>.*?\*\/))|\s*+;\s*+(})\s*+|\s*+([*$~^|]?+=|[{};,>~+]|\s*+-(?![0-9\.])|!important\b)\s*+|([[(:])\s++|\s++([])])|\s++(:)\s*+(?!(?>[^{}"\']++|"(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')*+{)|^\s++|\s++\z|(\s)\s+#si',
				// Replace `0(cm|em|ex|in|mm|pc|pt|px|vh|vw|%)` with `0`
				'#(?<=[\s:])(0)(cm|em|ex|in|mm|pc|pt|px|vh|vw|%)#si',
				// Replace `:0 0 0 0` with `:0`
				'#:(0\s+0|0\s+0\s+0\s+0)(?=[;\}]|\!important)#i',
				// Replace `background-position:0` with `background-position:0 0`
				'#(background-position):0(?=[;\}])#si',
				// Replace `0.6` with `.6`, but only when preceded by `:`, `,`, `-` or a white-space
				'#(?<=[\s:,\-])0+\.(\d+)#s',
				// Minify string value
				'#(\/\*(?>.*?\*\/))|(?<!content\:)([\'"])([a-z_][a-z0-9\-_]*?)\2(?=[\s\{\}\];,])#si',
				'#(\/\*(?>.*?\*\/))|(\burl\()([\'"])([^\s]+?)\3(\))#si',
				// Minify HEX color code
				'#(?<=[\s:,\-]\#)([a-f0-6]+)\1([a-f0-6]+)\2([a-f0-6]+)\3#i',
				// Replace `(border|outline):none` with `(border|outline):0`
				'#(?<=[\{;])(border|outline):none(?=[;\}\!])#',
				// Remove empty selector(s)
				'#(\/\*(?>.*?\*\/))|(^|[\{\}])(?:[^\s\{\}]+)\{\}#s'
			),
			array(
				'$1',
				'$1$2$3$4$5$6$7',
				'$1',
				':0',
				'$1:0 0',
				'.$1',
				'$1$3',
				'$1$2$4$5',
				'$1$2$3',
				'$1:0',
				'$1$2'
			),
		$input);
	}

	// JavaScript (JS) Minifier
	// Credits: https://gist.github.com/Rodrigo54/93169db48194d470188f, https://github.com/mecha-cms/extend.minify
	public static function minifyScripts( $input ) 
	{
		if(trim($input) === "") 
			return $input;
		
		return preg_replace(
			array(
				// Remove comment(s)
				'#\s*("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')\s*|\s*\/\*(?!\!|@cc_on)(?>[\s\S]*?\*\/)\s*|\s*(?<![\:\=])\/\/.*(?=[\n\r]|$)|^\s*|\s*$#',
				// Remove white-space(s) outside the string and regex
				'#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\'|\/\*(?>.*?\*\/)|\/(?!\/)[^\n\r]*?\/(?=[\s.,;]|[gimuy]|$))|\s*([!%&*\(\)\-=+\[\]\{\}|;:,.<>?\/])\s*#s',
				// Remove the last semicolon
				'#;+\}#',
				// Minify object attribute(s) except JSON attribute(s). From `{'foo':'bar'}` to `{foo:'bar'}`
				'#([\{,])([\'])(\d+|[a-z_][a-z0-9_]*)\2(?=\:)#i',
				// --ibid. From `foo['bar']` to `foo.bar`
				'#([a-z0-9_\)\]])\[([\'"])([a-z_][a-z0-9_]*)\2\]#i'
			),
			array(
				'$1',
				'$1$2',
				'}',
				'$1$3',
				'$1.$3'
			),
		$input);
	}
	
	public static function bundle( $bundleFilePath, array $assetPaths, $minifyFunc = null ): string
	{
		// Check  for existing bundle file
		$serverBundleFilePath = self::SERVER_ROOT_PATH . $bundleFilePath;
		if( !is_readable( $serverBundleFilePath ) ){

			// Open File Stream (Store in memory)
			$stream = fopen('php://memory','r+');
			
			// Loop the assets
			foreach( $assetPaths as $path ){
				
				// Ensure the asset can be read by PHP
				$serverAssetPath = self::SERVER_ROOT_PATH.$path;
				if( !is_readable( $serverAssetPath ) ){
// todo: We should throw an exception
// continue; } // Get the asset content $contents = file_get_contents( $serverAssetPath ); // Minify the content if the file name doesn't include the universal ".min." if( $minifyFunc != null && method_exists( self::class, $minifyFunc) && strpos($path, '.min.') === false ){ $contents = self::$minifyFunc( $contents ); } // Write the asset content to the stream fwrite($stream, $contents . PHP_EOL ); } // Return the Pointer to the start of the stream rewind($stream);
// todo: Check that PHP has file system write access. Throw errors where necessary.
//
// Copy the stream to the file system file_put_contents( $serverBundleFilePath, $stream ); // Close the stream fclose($stream); } return $bundleFilePath; } public static function css( $bundleFilePath, array $assetPaths ): string{ $bundleFilePath = self::bundle( $bundleFilePath, $assetPaths, 'minifyStyles' ); return '<link rel="stylesheet" href="'. $bundleFilePath.'">'; } public static function js( $bundleFilePath, array $assetPaths ): string{ $bundleFilePath = self::bundle( $bundleFilePath, $assetPaths, 'minifyScripts' ); return '<script src="'. $bundleFilePath.'" defer></script>'; } }

 

Additional

  1. PHP requires read/write access to the resources/assets folder on the web server.... Be sure you know what you're doing otherwise it could pose a security risk.
  2. Ensure your CSS files are valid before minifying. If you have open brackets ( { } ) or are missing semi-colons ( ; ) your minification may fail.
  3. Ensure your JS files are valid before minifying. If you have open brackets ( { } ) or are missing semi-colons ( ; ) your minification may fail.
  4. The bundling process will not repeat while the bundling file exists on the server. Either delete the existing bundle file or change the version number (1-0-0, 1-0-1, 1-0-2, etc)  of the bundle file.

 


PurcellYoon are a team of expert PHP Web Developers with a passion for creating exceptional digital experiences. We are committed to delivering superior PHP Applications for all our partners and clients.

We'd love to talk with you about PHP Web Development.
More questions? Get in touch.