1: <?php
2:
3: namespace Himedia\Padocc\Minifier;
4:
5: use GAubry\Shell\ShellAdapter;
6:
7: /**
8: * Compresser les fichiers JS et CSS.
9: *
10: * @author Geoffroy AUBRY <gaubry@hi-media.com>
11: */
12: class JSMinAdapter implements MinifierInterface
13: {
14:
15: /**
16: * Shell adapter.
17: *
18: * @var ShellAdapter
19: * @see minifyJS()
20: */
21: private $oShell;
22:
23: /**
24: * Chemin du binaire JSMin
25: *
26: * @var string
27: * @see minifyJS()
28: */
29: private $sBinPath;
30:
31: /**
32: * Constructeur.
33: *
34: * @param string $sJSMinBinPath chemin du binaire JSMin
35: * @param ShellAdapter $oShell instance utilisée pour exécuter le binaire jsmin
36: */
37: public function __construct ($sJSMinBinPath, ShellAdapter $oShell)
38: {
39: $this->sBinPath = $sJSMinBinPath;
40: $this->oShell = $oShell;
41: }
42:
43: /**
44: * Minifie la liste de fichiers JS ou CSS spécifiée et enregistre le résultat dans $sDestPath.
45: *
46: * @param array $aSrcPaths liste de fichiers se finissant tous par '.js', ou tous par '.css'
47: * @param string $sDestPath chemin/fichier dans lequel enregistrer le résultat du minify
48: * @return MinifierInterface $this
49: * @throws \BadMethodCallException si $aSrcPaths vide
50: * @throws \UnexpectedValueException si les sources n'ont pas toutes la même extension de fichier
51: * @throws \UnexpectedValueException si la destination est un CSS quand les sources sont des JS ou inversement
52: * @throws \DomainException si des fichiers ne se terminent ni par '.js', ni par '.css'
53: */
54: public function minify (array $aSrcPaths, $sDestPath)
55: {
56: if (count($aSrcPaths) === 0) {
57: throw new \BadMethodCallException('Source files missing!');
58: }
59:
60: // Est-ce que les fichiers en entrée sont tous des JS ou tous des CSS ?
61: $sFirstExtension = strrchr(reset($aSrcPaths), '.');
62: foreach ($aSrcPaths as $sSrcPath) {
63: $sExtension = strrchr($sSrcPath, '.');
64: if ($sExtension !== $sFirstExtension) {
65: throw new \UnexpectedValueException('All files must be either JS or CSS: ' . print_r($aSrcPaths, true));
66: }
67: }
68:
69: // La destination est-elle en accord avec les entrées ?
70: if (strrchr($sDestPath, '.') !== $sFirstExtension) {
71: $sMsg = "Destination file must be same type of input files: '$sDestPath' : Src :"
72: . print_r($aSrcPaths, true);
73: throw new \UnexpectedValueException($sMsg);
74: }
75:
76: // On redirige vers le service idoine :
77: switch ($sFirstExtension) {
78: case '.js':
79: $this->minifyJS($aSrcPaths, $sDestPath);
80: break;
81:
82: case '.css':
83: $this->minifyCSS($aSrcPaths, $sDestPath);
84: break;
85:
86: default:
87: $sMsg = "All specified paths must finish either by '.js' or '.css': '$sFirstExtension'!";
88: throw new \DomainException($sMsg);
89: break;
90: }
91:
92: return $this;
93: }
94:
95: /**
96: * Minifie la liste des fichiers JS spécifiée et enregistre le résultat dans $sDestPath.
97: *
98: * @param array $aSrcPaths liste de fichiers se finissant tous par '.js'
99: * @param string $sDestPath chemin/fichier dans lequel enregistrer le résultat du minify
100: * @throws \RuntimeException en cas d'erreur shell
101: */
102: protected function minifyJS (array $aSrcPaths, $sDestPath)
103: {
104: $sHeader = $this->getHeader($aSrcPaths);
105: $sCmd = 'cat';
106: foreach ($aSrcPaths as $sSrcPath) {
107: $sCmd .= ' ' . $this->oShell->escapePath($sSrcPath);
108: }
109: $sCmd .= " | $this->sBinPath >'$sDestPath' && sed --in-place '1i$sHeader' '$sDestPath'";
110: $this->oShell->exec($sCmd);
111: }
112:
113: /**
114: * Minifie la liste des fichiers CSS spécifiée et enregistre le résultat dans $sDestPath.
115: *
116: * @param array $aSrcPaths liste de fichiers se finissant tous par '.css'
117: * @param string $sDestPath chemin/fichier dans lequel enregistrer le résultat du minify
118: * @throws \RuntimeException si l'un des fichiers est introuvable
119: */
120: protected function minifyCSS (array $aSrcPaths, $sDestPath)
121: {
122: $sContent = $this->getContent($aSrcPaths);
123:
124: // remove comments:
125: $sContent = preg_replace('#/\*[^*]*\*+([^/][^*]*\*+)*/#', '', $sContent);
126:
127: // remove tabs, spaces, newlines, etc.
128: $sContent = str_replace(array("\r" , "\n" , "\t"), '', $sContent);
129: $sContent = str_replace(array(' ' , ' ' , ' '), ' ', $sContent);
130:
131: $sContent = $this->getHeader($aSrcPaths) . $sContent;
132: file_put_contents($sDestPath, $sContent);
133: }
134:
135: /**
136: * Retourne une ligne de commentaire, à insérer en 1re ligne d'un fichier CSS ou JS minifié,
137: * énumérant tous les fichiers sources le constituant.
138: *
139: * Par exemple :
140: * "/* Contains: /home/resources/a.css *[slash]\n"
141: * "/* Contains (basedir='/path/to/resources/'): a.txt, b.txt *[slash]\n"
142: *
143: * @param array $aSrcPaths liste de fichiers sources
144: * @return string une ligne de commentaire, à insérer en 1re ligne d'un fichier CSS ou JS minifié,
145: * énumérant tous les fichiers sources le constituant.
146: */
147: private function getHeader (array $aSrcPaths)
148: {
149: if (count($aSrcPaths) === 1) {
150: $sHeader = "/* Contains: " . reset($aSrcPaths) . ' */' . "\n";
151: } else {
152: $sCommonPrefix = $this->getLargestCommonPrefix($aSrcPaths);
153: $iPrefixLength = strlen($sCommonPrefix);
154: $aShortPaths = array();
155: foreach ($aSrcPaths as $sSrcPath) {
156: $aShortPaths[] = substr($sSrcPath, $iPrefixLength);
157: }
158: $sHeader = "/* Contains (basedir='$sCommonPrefix'): " . implode(', ', $aShortPaths) . ' */' . "\n";
159: }
160: return $sHeader;
161: }
162:
163: /**
164: * Retourne le plus long préfixe commun aux chaînes fournies.
165: *
166: * @param array $aStrings liste de chaînes à comparer
167: * @return string le plus long préfixe commun aux chaînes fournies.
168: * @see http://stackoverflow.com/questions/1336207/finding-common-prefix-of-array-of-strings/1336357#1336357
169: */
170: private function getLargestCommonPrefix (array $aStrings)
171: {
172: // take the first item as initial prefix:
173: $sPrefix = array_shift($aStrings);
174: $iLength = strlen($sPrefix);
175:
176: // compare the current prefix with the prefix of the same length of the other items
177: foreach ($aStrings as $sItem) {
178:
179: // check if there is a match; if not, decrease the prefix by one character at a time
180: while ($iLength > 0 && substr($sItem, 0, $iLength) !== $sPrefix) {
181: $iLength--;
182: $sPrefix = substr($sPrefix, 0, -1);
183: }
184:
185: if ($iLength === 0) {
186: break;
187: }
188: }
189:
190: return $sPrefix;
191: }
192:
193: /**
194: * Retourne la concaténation du contenu des fichiers spécifiés.
195: *
196: * @param array $aSrcPaths liste de chemins dont on veut concaténer le contenu
197: * @return string la concaténation du contenu des fichiers spécifiés.
198: * @throws \RuntimeException si l'un des fichiers est introuvable
199: * @see minifyCSS()
200: */
201: private function getContent (array $aSrcPaths)
202: {
203: $aExpandedPaths = array();
204: foreach ($aSrcPaths as $sPath) {
205: if (strpos($sPath, '*') !== false || strpos($sPath, '?') !== false) {
206: $aExpandedPaths = array_merge($aExpandedPaths, glob($sPath));
207: } else {
208: $aExpandedPaths[] = $sPath;
209: }
210: }
211:
212: $sContent = '';
213: foreach ($aExpandedPaths as $sPath) {
214: try {
215: $sContent .= file_get_contents($sPath);
216: } catch (\Exception $oException) {
217: throw new \RuntimeException("File not found: '$sPath'!", 1, $oException);
218: }
219: }
220: return $sContent;
221: }
222: }
223: