1<?php
2
3/*
4 * This file is part of the Assetic package, an OpenSky project.
5 *
6 * (c) 2010-2014 OpenSky Project Inc
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Assetic\Factory;
13
14use Assetic\Asset\AssetCollection;
15use Assetic\Asset\AssetCollectionInterface;
16use Assetic\Asset\AssetInterface;
17use Assetic\Asset\AssetReference;
18use Assetic\Asset\FileAsset;
19use Assetic\Asset\GlobAsset;
20use Assetic\Asset\HttpAsset;
21use Assetic\AssetManager;
22use Assetic\Factory\Worker\WorkerInterface;
23use Assetic\Filter\DependencyExtractorInterface;
24use Assetic\FilterManager;
25
26/**
27 * The asset factory creates asset objects.
28 *
29 * @author Kris Wallsmith <kris.wallsmith@gmail.com>
30 */
31class AssetFactory
32{
33    private $root;
34    private $debug;
35    private $output;
36    private $workers;
37    private $am;
38    private $fm;
39
40    /**
41     * Constructor.
42     *
43     * @param string  $root  The default root directory
44     * @param Boolean $debug Filters prefixed with a "?" will be omitted in debug mode
45     */
46    public function __construct($root, $debug = false)
47    {
48        $this->root      = rtrim($root, '/');
49        $this->debug     = $debug;
50        $this->output    = 'assetic/*';
51        $this->workers   = array();
52    }
53
54    /**
55     * Sets debug mode for the current factory.
56     *
57     * @param Boolean $debug Debug mode
58     */
59    public function setDebug($debug)
60    {
61        $this->debug = $debug;
62    }
63
64    /**
65     * Checks if the factory is in debug mode.
66     *
67     * @return Boolean Debug mode
68     */
69    public function isDebug()
70    {
71        return $this->debug;
72    }
73
74    /**
75     * Sets the default output string.
76     *
77     * @param string $output The default output string
78     */
79    public function setDefaultOutput($output)
80    {
81        $this->output = $output;
82    }
83
84    /**
85     * Adds a factory worker.
86     *
87     * @param WorkerInterface $worker A worker
88     */
89    public function addWorker(WorkerInterface $worker)
90    {
91        $this->workers[] = $worker;
92    }
93
94    /**
95     * Returns the current asset manager.
96     *
97     * @return AssetManager|null The asset manager
98     */
99    public function getAssetManager()
100    {
101        return $this->am;
102    }
103
104    /**
105     * Sets the asset manager to use when creating asset references.
106     *
107     * @param AssetManager $am The asset manager
108     */
109    public function setAssetManager(AssetManager $am)
110    {
111        $this->am = $am;
112    }
113
114    /**
115     * Returns the current filter manager.
116     *
117     * @return FilterManager|null The filter manager
118     */
119    public function getFilterManager()
120    {
121        return $this->fm;
122    }
123
124    /**
125     * Sets the filter manager to use when adding filters.
126     *
127     * @param FilterManager $fm The filter manager
128     */
129    public function setFilterManager(FilterManager $fm)
130    {
131        $this->fm = $fm;
132    }
133
134    /**
135     * Creates a new asset.
136     *
137     * Prefixing a filter name with a question mark will cause it to be
138     * omitted when the factory is in debug mode.
139     *
140     * Available options:
141     *
142     *  * output: An output string
143     *  * name:   An asset name for interpolation in output patterns
144     *  * debug:  Forces debug mode on or off for this asset
145     *  * root:   An array or string of more root directories
146     *
147     * @param array|string $inputs  An array of input strings
148     * @param array|string $filters An array of filter names
149     * @param array        $options An array of options
150     *
151     * @return AssetCollection An asset collection
152     */
153    public function createAsset($inputs = array(), $filters = array(), array $options = array())
154    {
155        if (!is_array($inputs)) {
156            $inputs = array($inputs);
157        }
158
159        if (!is_array($filters)) {
160            $filters = array($filters);
161        }
162
163        if (!isset($options['output'])) {
164            $options['output'] = $this->output;
165        }
166
167        if (!isset($options['vars'])) {
168            $options['vars'] = array();
169        }
170
171        if (!isset($options['debug'])) {
172            $options['debug'] = $this->debug;
173        }
174
175        if (!isset($options['root'])) {
176            $options['root'] = array($this->root);
177        } else {
178            if (!is_array($options['root'])) {
179                $options['root'] = array($options['root']);
180            }
181
182            $options['root'][] = $this->root;
183        }
184
185        if (!isset($options['name'])) {
186            $options['name'] = $this->generateAssetName($inputs, $filters, $options);
187        }
188
189        $asset = $this->createAssetCollection(array(), $options);
190        $extensions = array();
191
192        // inner assets
193        foreach ($inputs as $input) {
194            if (is_array($input)) {
195                // nested formula
196                $asset->add(call_user_func_array(array($this, 'createAsset'), $input));
197            } else {
198                $asset->add($this->parseInput($input, $options));
199                $extensions[pathinfo($input, PATHINFO_EXTENSION)] = true;
200            }
201        }
202
203        // filters
204        foreach ($filters as $filter) {
205            if ('?' != $filter[0]) {
206                $asset->ensureFilter($this->getFilter($filter));
207            } elseif (!$options['debug']) {
208                $asset->ensureFilter($this->getFilter(substr($filter, 1)));
209            }
210        }
211
212        // append variables
213        if (!empty($options['vars'])) {
214            $toAdd = array();
215            foreach ($options['vars'] as $var) {
216                if (false !== strpos($options['output'], '{'.$var.'}')) {
217                    continue;
218                }
219
220                $toAdd[] = '{'.$var.'}';
221            }
222
223            if ($toAdd) {
224                $options['output'] = str_replace('*', '*.'.implode('.', $toAdd), $options['output']);
225            }
226        }
227
228        // append consensus extension if missing
229        if (1 == count($extensions) && !pathinfo($options['output'], PATHINFO_EXTENSION) && $extension = key($extensions)) {
230            $options['output'] .= '.'.$extension;
231        }
232
233        // output --> target url
234        $asset->setTargetPath(str_replace('*', $options['name'], $options['output']));
235
236        // apply workers and return
237        return $this->applyWorkers($asset);
238    }
239
240    public function generateAssetName($inputs, $filters, $options = array())
241    {
242        foreach (array_diff(array_keys($options), array('output', 'debug', 'root')) as $key) {
243            unset($options[$key]);
244        }
245
246        ksort($options);
247
248        return substr(sha1(serialize($inputs).serialize($filters).serialize($options)), 0, 7);
249    }
250
251    public function getLastModified(AssetInterface $asset)
252    {
253        $mtime = 0;
254        foreach ($asset instanceof AssetCollectionInterface ? $asset : array($asset) as $leaf) {
255            $mtime = max($mtime, $leaf->getLastModified());
256
257            if (!$filters = $leaf->getFilters()) {
258                continue;
259            }
260
261            $prevFilters = array();
262            foreach ($filters as $filter) {
263                $prevFilters[] = $filter;
264
265                if (!$filter instanceof DependencyExtractorInterface) {
266                    continue;
267                }
268
269                // extract children from leaf after running all preceeding filters
270                $clone = clone $leaf;
271                $clone->clearFilters();
272                foreach (array_slice($prevFilters, 0, -1) as $prevFilter) {
273                    $clone->ensureFilter($prevFilter);
274                }
275                $clone->load();
276
277                foreach ($filter->getChildren($this, $clone->getContent(), $clone->getSourceDirectory()) as $child) {
278                    $mtime = max($mtime, $this->getLastModified($child));
279                }
280            }
281        }
282
283        return $mtime;
284    }
285
286    /**
287     * Parses an input string string into an asset.
288     *
289     * The input string can be one of the following:
290     *
291     *  * A reference:     If the string starts with an "at" sign it will be interpreted as a reference to an asset in the asset manager
292     *  * An absolute URL: If the string contains "://" or starts with "//" it will be interpreted as an HTTP asset
293     *  * A glob:          If the string contains a "*" it will be interpreted as a glob
294     *  * A path:          Otherwise the string is interpreted as a filesystem path
295     *
296     * Both globs and paths will be absolutized using the current root directory.
297     *
298     * @param string $input   An input string
299     * @param array  $options An array of options
300     *
301     * @return AssetInterface An asset
302     */
303    protected function parseInput($input, array $options = array())
304    {
305        if ('@' == $input[0]) {
306            return $this->createAssetReference(substr($input, 1));
307        }
308
309        if (false !== strpos($input, '://') || 0 === strpos($input, '//')) {
310            return $this->createHttpAsset($input, $options['vars']);
311        }
312
313        if (self::isAbsolutePath($input)) {
314            if ($root = self::findRootDir($input, $options['root'])) {
315                $path = ltrim(substr($input, strlen($root)), '/');
316            } else {
317                $path = null;
318            }
319        } else {
320            $root  = $this->root;
321            $path  = $input;
322            $input = $this->root.'/'.$path;
323        }
324
325        if (false !== strpos($input, '*')) {
326            return $this->createGlobAsset($input, $root, $options['vars']);
327        }
328
329        return $this->createFileAsset($input, $root, $path, $options['vars']);
330    }
331
332    protected function createAssetCollection(array $assets = array(), array $options = array())
333    {
334        return new AssetCollection($assets, array(), null, isset($options['vars']) ? $options['vars'] : array());
335    }
336
337    protected function createAssetReference($name)
338    {
339        if (!$this->am) {
340            throw new \LogicException('There is no asset manager.');
341        }
342
343        return new AssetReference($this->am, $name);
344    }
345
346    protected function createHttpAsset($sourceUrl, $vars)
347    {
348        return new HttpAsset($sourceUrl, array(), false, $vars);
349    }
350
351    protected function createGlobAsset($glob, $root = null, $vars)
352    {
353        return new GlobAsset($glob, array(), $root, $vars);
354    }
355
356    protected function createFileAsset($source, $root = null, $path = null, $vars)
357    {
358        return new FileAsset($source, array(), $root, $path, $vars);
359    }
360
361    protected function getFilter($name)
362    {
363        if (!$this->fm) {
364            throw new \LogicException('There is no filter manager.');
365        }
366
367        return $this->fm->get($name);
368    }
369
370    /**
371     * Filters an asset collection through the factory workers.
372     *
373     * Each leaf asset will be processed first, followed by the asset
374     * collection itself.
375     *
376     * @param AssetCollectionInterface $asset An asset collection
377     *
378     * @return AssetCollectionInterface
379     */
380    private function applyWorkers(AssetCollectionInterface $asset)
381    {
382        foreach ($asset as $leaf) {
383            foreach ($this->workers as $worker) {
384                $retval = $worker->process($leaf, $this);
385
386                if ($retval instanceof AssetInterface && $leaf !== $retval) {
387                    $asset->replaceLeaf($leaf, $retval);
388                }
389            }
390        }
391
392        foreach ($this->workers as $worker) {
393            $retval = $worker->process($asset, $this);
394
395            if ($retval instanceof AssetInterface) {
396                $asset = $retval;
397            }
398        }
399
400        return $asset instanceof AssetCollectionInterface ? $asset : $this->createAssetCollection(array($asset));
401    }
402
403    private static function isAbsolutePath($path)
404    {
405        return '/' == $path[0] || '\\' == $path[0] || (3 < strlen($path) && ctype_alpha($path[0]) && $path[1] == ':' && ('\\' == $path[2] || '/' == $path[2]));
406    }
407
408    /**
409     * Loops through the root directories and returns the first match.
410     *
411     * @param string $path  An absolute path
412     * @param array  $roots An array of root directories
413     *
414     * @return string|null The matching root directory, if found
415     */
416    private static function findRootDir($path, array $roots)
417    {
418        foreach ($roots as $root) {
419            if (0 === strpos($path, $root)) {
420                return $root;
421            }
422        }
423    }
424}
425