JS/CSS minifier

Minification de JS/CSS à la volée

Avril 2007

Les bonnes pratiques de développement web nous apprennent uns chose simple : compresser les flux au maximum, et limiter le nombre de hits.

Dans cet objectif, j'ai créé une classe de compression gzip à la volée de mes fichiers JS et CSS.

Cette classe gère les versions concatenées et gzippées des fichiers en cache.

Fonctionnalités

Remarque : les classes nécessitent PHP5. Je ne développe plus pour PHP4, il faut savoir un jour tourner la page. Pour moi c'est fait depuis un bail. Je vous laisse le soin de tout porter en PHP4 si vous y tenez quand même...

  • Minification des fichiers JS et CSS (pour le JS, j'utilise le portage PHP fait par Ryan Grove de JSMin, script de Douglas Crockford's)
  • Concaténation des fichiers (diminue le nombre de hits)
  • Compression Gzip (si le navigateur le supporte, bien évidemment)

Coté .htaccess

Histoire d'éviter les lien biscornus, je préfère utiliser du rewriting via htaccess. Je ne vais pas tout vous expliquer, vous avez l'idée, à vous de l'adapter, ca ressemble à ça :

RewriteEngine On
RewriteRule ^(.*\.(?:[jJ][sS]|[cC][sS][sS]|[xX][mM][lL]))$ /gzip.php?uri=$1 [L]

Coté gzip.php

<?php
include('class.gzip_browser.php');
include('class.jsmin.php');

$gzip = new gzip_browser('uri');
$gzip->exit = true;
$gzip->document_root = './';
$gzip->gz_folder = 'cache/gzip/';
$gzip->send();
?>

Coté intégration HTML

Si vous voulez voir à quoi ça ressemble "in fine", affichez le code HTML de ce site ;)

<link rel="stylesheet" type="text/css" href="/chemin/fichier.css" />
<link rel="stylesheet" type="text/css" href="/chemin/fichier1,fichier2.css" />

<script src="/chemin/fichier.js" type="text/javascript"></script>
<script src="/chemin/fichier1,fichier2.js" type="text/javascript"></script>

Fonctionnalités techniques de la classe

Je vous encourage à lire le code source, rien de tel pour tout comprendre... puis la classe n'est pas très compliquée.

En synthèse brève, voilà les paramètres principaux

  • $gzip->param : nom du paramètre en GET utilisé (si vide, vous devez renseigner value). Ce paramètre est le premier paramètre du constructeur de la classe aussi.
  • $gzip->value : uri utilisée (si vide, vous devez renseigner param).
  • $gzip->exit : indique si la classe fait un exit en cas d'erreur 403 et 404 (au cas ou vous voulez les gérer vous même par derrière). La méthode send retournera false en cas d'erreur.
  • $gzip->document_root : c'est la racine de votre site, bref ça vous sert si vous utilisez le script dans un sous-dossier de votre racine.
  • $gzip->gz_folder : le dossier de cache, évidemment doit être accessible en écriture hein ;).
  • $gzip->cache_days : durée du cache si aucune modif n'est faite sur les fichiers entre temps (environ 10 ans par défaut, normalement, pas besoin de toucher à ça).
  • $gzip->js_replacements : hashtable de remplacements à faire lors de l'analyse des fichiers (ça me sert pour initialiser des variables JS à la volée issues de la conf du site).
  • $gzip->css_replacements : idem que js_replacements mais pour les css.
  • $gzip->minify_js : true ou false selon que vous vouliez ou pas désactiver la minification.
  • $gzip->minify_css : true ou false selon que vous vouliez ou pas désactiver la minification.

La classe

<?php

/**
 * gzip_browser class
 */
class gzip_browser {

	/**
	 *
	 */
	public $css_replacements = array();
	public $js_replacements = array();

	/**
	 *
	 */
	public $exit = false;
	public $method = 'get'; // or post or request
	public $param = 'uri';
	public $gz_folder = './';
	public $cache_days = 3600;
	public $document_root;
	public $document_realroot;
	public $last_modified = 0;
	public $mime_types = array(
		'htm' => 'text/html',
		'html' => 'text/html',
		'js' => 'text/javascript',
		'css' => 'text/css',
		'xml' => 'text/xml',
		'txt' => 'text/plain'
	);
	public $value = '';
	public $minify_js = true;
	public $minify_css = true;

	/**
	 *
	 */
	protected $src_uri = array();
	protected $gz_uri = '';
	protected $file_uri = '';
	protected $uri_root = '';
	protected $mime_type = '';
	protected $mime_ext = '';
	protected $browser_gzip = false;

	/**
	 *
	 * @param string $param
	 */
	public function __construct($param = '', $uri_root = '', $document_root = '.') {
		$this->param = $param;
		$this->uri_root = $uri_root;
		$this->document_root = $document_root;
	}

	/**
	 * 
	 */
	public static function css_wordwrap($data) {
		$str = $data[1];
		if (strpos($str, ',') !== FALSE) {
			$items = explode(', ', $str);
			$ret = '';
			$s = 0;
			foreach ($items as $item) {
				$n = strlen($item);
				if (empty($ret)) {
					$ret .= $item;
					$s = $n;
				}
				elseif ($s + $n > 38) {
					$ret .= ",\n" . $item;
					$s = $n;
				}
				else {
					$ret .= ", " . $item;
					$s += $n + 2;
				}
			}
			return $ret;
		}
		else {
			return $str;
		}
	}

	/**
	 *
	 * @param <type> $filename 
	 */
	protected function getJS($filename) {
		$js = file_get_contents($filename);
		$js = strtr($js, $this->js_replacements);
		if (class_exists('JSMin') && $this->minify_js) {
			$js = trim(JSMin::minify($js));
		}
		$js = "// " . basename($filename) . "\n" . $js . "\n\n";
		return $js;
	}

	/**
	 *
	 * @param <type> $filename
	 */
	protected function getCSS($filename) {
		$css = trim(file_get_contents($filename));
		$css = strtr($css, $this->css_replacements);
		if ($this->minify_css) {
			$css = preg_replace('!/\*.*?\*/!si', '', $css);
			$css = preg_replace('!\t|\r|\n!si', '', $css);
			$css = preg_replace('! *(;|,|:|\{|\}) *!si', '$1', $css);
			$css = str_replace('}', "}\n", $css);
		}
		$css = "/***[ " . basename($filename) . " ]***/\n" . $css . "\n\n\n";
		return $css;
	}

	/**
	 *
	 * @return bool
	 */
	protected function gz_compress() {
		$ret = false;
		$file = fopen($this->file_uri, 'wb');
		$filegz = gzopen($this->gz_uri, 'wb9');
		if ($filegz && $file) {
			if ($this->mime_ext == 'js') {
				foreach ($this->src_uri as $source) {
					$content = $this->getJS($source);
					fwrite($file, $content);
					gzwrite($filegz, $content);
				}
			}
			elseif ($this->mime_ext == 'css') {
				foreach ($this->src_uri as $source) {
					$content = $this->getCSS($source);
					fwrite($file, $content);
					gzwrite($filegz, $content);
				}
			}
			else {
				foreach ($this->src_uri as $source) {
					$content = file_get_contents($source) . "\n\n\n";
					fwrite($file, $content);
					gzwrite($filegz, $content);
				}
			}
			gzclose($filegz);
			fclose($file);
			$ret = true;
		}
		@chmod($this->file_uri, 0666);
		@chmod($this->gz_uri, 0666);
		return $ret;
	}

	/**
	 *
	 */
	public function send() {
		if (!$this->security_checks()) {
			return false;
		}
		$this->cache_headers();

		if (!file_exists(dirname($this->gz_uri))) {
			$this->mkdir(dirname($this->gz_uri));
		}

		if (file_exists($this->gz_uri)) {
			$src_last_modified = $this->last_modified;
			$dst_last_modified = filemtime($this->gz_uri);
			// The gzip version of the file exists, but it is older
			// than the source file. We need to recreate it...
			if ($src_last_modified > $dst_last_modified) {
				@unlink($this->gz_uri);
				@unlink($this->file_uri);
			}
		}

		if (!file_exists($this->gz_uri)) {
			if (!$this->gz_compress()) {
				if (!$this->exit) {
					return false;
				}
				header('HTTP/1.1 404 Not Found');
				echo('<html><body><h1>HTTP 404 - Not Found (4)</h1></body></html>');
				exit;
			}
		}

		// check browser gzip support
		$this->browser_gzip = true;
		// Let's compress only text files...
		$this->browser_gzip = $this->browser_gzip && (strpos($this->mime_type, 'text') !== false );
		// Finally, see if the client sent us the correct Accept-encoding: header value...
		if (isset($_SERVER['HTTP_ACCEPT_ENCODING'])) {
			$this->browser_gzip = $this->browser_gzip && (strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false );
		}
		else {
			$this->browser_gzip = false;
		}

		if ($this->browser_gzip) {
			header('Content-Encoding: gzip');
			header("Content-Type: " . $this->mime_type);
			header("Content-Length: " . filesize($this->gz_uri));
			$this->readfile($this->gz_uri);
		}
		else {
			header("Content-Type: " . $this->mime_type);
			header("Content-Length: " . filesize($this->file_uri));
			$this->readfile($this->file_uri);
		}
		exit;
	}

	/**
	 *
	 * @param string $filename
	 */
	protected function readfile($filename) {
		if (file_exists($filename)) {
			$fh = fopen($filename, 'rb');
			while (!feof($fh)) {
				echo fread($fh, 65536);
			}
			fclose($fh);
		}
	}

	/**
	 *
	 * @param string $dir_name
	 */
	protected function mkdir($dir_name) {
		$dirs = explode('/', $dir_name);
		$dir = '';
		foreach ($dirs as $part) {
			$dir .= $part . '/';
			if (!is_dir($dir) && strlen($dir) > 0) {
				mkdir($dir);
				chmod($dir, 0755);
			}
		}
	}

	/**
	 *
	 */
	protected function cache_headers() {
		$max_age = $this->cache_days * 86400;

		$this->last_modified = 0;
		foreach ($this->src_uri as $uri) {
			$t = filemtime($uri);
			if ($t > $this->last_modified) {
				$this->last_modified = $t;
			}
		}
		header('Last-Modified: ' . date('r', $this->last_modified));

		$expires = $this->last_modified + $max_age;
		header('Expires: ' . date('r', $expires));

		$etag = dechex($this->last_modified);
		header('ETag: ' . $etag);

		$cache_control = 'must-revalidate, proxy-revalidate, max-age=86400, s-maxage=86400';
		header('Cache-Control: ' . $cache_control);

		// Check if the client should use the cached version. Return HTTP 304 if needed.
		if (function_exists('http_match_etag') && function_exists('http_match_modified')) {
			if (http_match_etag($etag) || http_match_modified($this->last_modified)) {
				header('HTTP/1.1 304 Not Modified');
				exit;
			}
		}
		else {
			error_log('The HTTP extensions to PHP does not seem to be installed...');
		}
	}

	/**
	 *
	 */
	protected function get_mime_type($filename, & $mime, & $ext) {
		$filename = basename($filename);
		$ext = '';
		$i = strrpos($filename, '.');
		if ($i !== false) {
			$ext = strtolower(substr($filename, $i + 1, strlen($filename) - $i - 1));
		}
		if (isset($this->mime_types[$ext])) {
			$mime = $this->mime_types[$ext];
			$ext = $ext;
			return TRUE;
		}
		else {
			$mime = '';
			$ext = '';
			return FALSE;
		}
	}

	/**
	 *
	 */
	protected function security_checks() {

		if (!empty($this->param)) {
			$VARS = null;
			switch (strtoupper($this->method)) {
				case 'GET' : $VARS = & $_GET;
					break;
				case 'POST' : $VARS = & $_POST;
					break;
				case 'REQUEST' : $VARS = & $_REQUEST;
					break;
			}
			$this->gz_folder = preg_replace('!/+$!', '/', $this->gz_folder);

			// the parameter is not set
			if (!isset($VARS[$this->param])) {
				header('HTTP/1.1 400 Bad Request');
				echo('<html><body><h1>HTTP 400 - Bad Request</h1></body></html>');
				exit;
			}
			$uris = explode(',', $VARS[$this->param]);
		}
		else {
			if (empty($this->value)) {
				header('HTTP/1.1 400 Bad Request');
				echo('<html><body><h1>HTTP 400 - Bad Request</h1></body></html>');
				exit;
			}
			$uris = explode(',', $this->value);
		}

		// check uris and mime types
		$current_folder = '';
		foreach ($uris as $uri) {
			$folder = dirname($uri);
			if (empty($folder) || $folder == '.') {
				$uri = $current_folder . $uri;
			}
			else {
				$current_folder = $folder . '/';
			}
			$uri = preg_replace('!//+!si', '/', $this->uri_root . $uri);
			if ($this->get_mime_type($uri, $mime, $ext)) {
				if (empty($this->mime_type)) {
					$this->mime_type = $mime;
					$this->mime_ext = $ext;
				}
				elseif ($this->mime_type != $mime) {
					if (!$this->exit) {
						return false;
					}
					header('HTTP/1.1 404 Not Found');
					echo('<html><body><h1>HTTP 404 - Not Found (1)</h1></body></html>');
					exit;
				}
			}
			$this->src_uri[] = $uri;
		}

		if (empty($this->mime_type)) {
			if (!$this->exit) {
				return false;
			}
			header('HTTP/1.1 404 Not Found');
			echo('<html><body><h1>HTTP 404 - Not Found (2)</h1></body></html>');
			exit;
		}

		// extensions uri
		$this->document_realroot = realpath($this->document_root);
		$uris = $this->src_uri;
		$this->src_uri = array();
		$folder = '';
		$list = '';
		$md5 = false;
		foreach ($uris as $uri) {

			// check if exists
			$uri = $this->document_realroot . '/' . preg_replace('/\.' . $this->mime_ext . '$/si', '', $uri) . '.' . $this->mime_ext;
			if (!file_exists($uri)) {
				if (!$this->exit) {
					return false;
				}
				header('HTTP/1.1 404 Not Found');
				echo('<html><body><h1>HTTP 404 - Not Found (3)</h1></body></html>');
				exit;
			}
			$this->src_uri[] = $uri;
			$list .= $uri . "\n";

			// the file is not in the web site folders
			$real_uri = realpath($uri);
			//echo "$real_uri<br />$real_root";
			if (substr($real_uri, 0, strlen($this->document_realroot)) !== $this->document_realroot) {
				if (!$this->exit) {
					return false;
				}
				header('HTTP/1.1 403 Forbidden');
				echo('<html><body><h1>HTTP 403 - Forbidden</h1></body></html>');
				exit;
			}
		}
		//die(print_r($this->src_uri, true));
		// gz_uri
		$this->file_uri = $this->gz_folder . $this->mime_ext . '.' . md5($list);
		$this->gz_uri = $this->file_uri . '.gz';

		return true;
	}

}

Téléchargement

gzip_browser.zip