1 <?php
2 
3 /*
4  * This file is part of Component Installer.
5  *
6  * (c) Rob Loach (http://robloach.net)
7  *
8  * For the full copyright and license information, please view the LICENSE.md
9  * file that was distributed with this source code.
10  */
11 
12 namespace ComponentInstaller\Process;
13 
14 use Assetic\Asset\StringAsset;
15 use Composer\Config;
16 use Composer\Json\JsonFile;
17 use Assetic\Asset\AssetCollection;
18 use Assetic\Asset\FileAsset;
19 
20 /**
21  * Builds the require.js configuration.
22  */
23 class RequireJsProcess extends Process
24 {
25     /**
26      * The base URL for the require.js configuration.
27      */
28     protected $baseUrl = 'components';
29 
30     /**
31      * {@inheritdoc}
32      */
33     public function init()
34     {
35         $output = parent::init();
36         if ($this->config->has('component-baseurl')) {
37             $this->baseUrl = $this->config->get('component-baseurl');
38         }
39 
40         return $output;
41     }
42 
43     /**
44      * {@inheritdoc}
45      */
46     public function process()
47     {
48         // Construct the require.js and stick it in the destination.
49         $json = $this->requireJson($this->packages, $this->config);
50         $requireConfig = $this->requireJs($json);
51 
52         // Attempt to write the require.config.js file.
53         $destination = $this->componentDir . '/require.config.js';
54         $this->fs->ensureDirectoryExists(dirname($destination));
55         if (file_put_contents($destination, $requireConfig) === FALSE) {
56             $this->io->write('<error>Error writing require.config.js</error>');
57 
58             return false;
59         }
60 
61         // Read in require.js to prepare the final require.js.
62         if (!file_exists(dirname(__DIR__) . '/Resources/require.js')) {
63             $this->io->write('<error>Error reading in require.js</error>');
64 
65             return false;
66         }
67 
68         $assets = $this->newAssetCollection();
69         $assets->add(new FileAsset(dirname(__DIR__) . '/Resources/require.js'));
70         $assets->add(new StringAsset($requireConfig));
71 
72         // Append the config to the require.js and write it.
73         if (file_put_contents($this->componentDir . '/require.js', $assets->dump()) === FALSE) {
74             $this->io->write('<error>Error writing require.js to the components directory</error>');
75 
76             return false;
77         }
78 
79         return null;
80     }
81 
82     /**
83      * Creates a require.js configuration from an array of packages.
84      *
85      * @param $packages
86      *   An array of packages from the composer.lock file.
87      *
88      * @return array
89      *   The built JSON array.
90      */
91     public function requireJson(array $packages)
92     {
93         $json = array();
94 
95         // Construct the packages configuration.
96         foreach ($packages as $package) {
97             // Retrieve information from the extra options.
98             $extra = isset($package['extra']) ? $package['extra'] : array();
99             $options = isset($extra['component']) ? $extra['component'] : array();
100 
101             // Construct the base details.
102             $name = $this->getComponentName($package['name'], $extra);
103             $component = array(
104                 'name' => $name,
105             );
106 
107             // Build the "main" directive.
108             $scripts = isset($options['scripts']) ? $options['scripts'] : array();
109             if (!empty($scripts)) {
110                 // Put all scripts into a build.js file.
111                 $result = $this->aggregateScripts($package, $scripts, $name.DIRECTORY_SEPARATOR.$name.'-built.js');
112                 if ($result) {
113                     // If the aggregation was successful, add the script to the
114                     // packages array.
115                     $component['main'] = $name.'-built.js';
116 
117                     // Add the component to the packages array.
118                     $json['packages'][] = $component;
119                 }
120             }
121 
122             // Add the shim definition for the package.
123             $shim = isset($options['shim']) ? $options['shim'] : array();
124             if (!empty($shim)) {
125                 $json['shim'][$name] = $shim;
126             }
127 
128             // Add the config definition for the package.
129             $packageConfig = isset($options['config']) ? $options['config'] : array();
130             if (!empty($packageConfig)) {
131                 $json['config'][$name] = $packageConfig;
132             }
133         }
134 
135         // Provide the baseUrl.
136         $json['baseUrl'] = $this->baseUrl;
137 
138         // Merge in configuration options from the root.
139         if ($this->config->has('component')) {
140             $config = $this->config->get('component');
141             if (isset($config) && is_array($config)) {
142                 // Use a recursive, distict array merge.
143                 $json = $this->arrayMergeRecursiveDistinct($json, $config);
144             }
145         }
146 
147         return $json;
148     }
149 
150     /**
151      * Concatenate all scripts together into one destination file.
152      *
153      * @param array $package
154      * @param array $scripts
155      * @param string $file
156      * @return bool
157      */
158     public function aggregateScripts($package, array $scripts, $file)
159     {
160         $assets = $this->newAssetCollection();
161 
162         foreach ($scripts as $script) {
163             // Collect each candidate from a glob file search.
164             $path = $this->getVendorDir($package).DIRECTORY_SEPARATOR.$script;
165             $matches = $this->fs->recursiveGlobFiles($path);
166             foreach ($matches as $match) {
167                 $assets->add(new FileAsset($match));
168             }
169         }
170         $js = $assets->dump();
171 
172         // Write the file if there are any JavaScript assets.
173         if (!empty($js)) {
174             $destination = $this->componentDir.DIRECTORY_SEPARATOR.$file;
175             $this->fs->ensureDirectoryExists(dirname($destination));
176 
177             return file_put_contents($destination, $js);
178         }
179 
180         return false;
181     }
182 
183     /**
184      * Constructs the require.js file from the provided require.js JSON array.
185      *
186      * @param $json
187      *   The require.js JSON configuration.
188      *
189      * @return string
190      *   The RequireJS JavaScript configuration.
191      */
192     public function requireJs(array $json = array())
193     {
194         // Encode the array to a JSON array.
195         $js = JsonFile::encode($json);
196 
197         // Construct the JavaScript output.
198         $output = <<<EOT
199 var components = $js;
200 if (typeof require !== "undefined" && require.config) {
201     require.config(components);
202 } else {
203     var require = components;
204 }
205 if (typeof exports !== "undefined" && typeof module !== "undefined") {
206     module.exports = components;
207 }
208 EOT;
209 
210         return $output;
211     }
212 
213     /**
214      * Merges two arrays without changing string array keys. Appends to array if keys are numeric.
215      *
216      * @see array_merge()
217      * @see array_merge_recursive()
218      *
219      * @param array $array1
220      * @param array $array2
221      * @return array
222      */
223     protected function arrayMergeRecursiveDistinct(array &$array1, array &$array2)
224     {
225         $merged = $array1;
226 
227         foreach ($array2 as $key => &$value) {
228             if(is_numeric($key)){
229                 $merged[] = $value;
230             } else {
231                 if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
232                     $merged[$key] = $this->arrayMergeRecursiveDistinct($merged[$key], $value);
233                 }
234                 else {
235                     $merged[$key] = $value;
236                 }
237             }
238         }
239 
240         return $merged;
241     }
242 
243     /**
244      * @return AssetCollection
245      */
246     protected function newAssetCollection()
247     {
248         // Aggregate all the assets into one file.
249         $assets = new AssetCollection();
250         if ($this->config->has('component-scriptFilters')) {
251             $filters = $this->config->get('component-scriptFilters');
252             if (isset($filters) && is_array($filters)) {
253                 foreach ($filters as $filter => $filterParams) {
254                     $reflection = new \ReflectionClass($filter);
255                     /** @var \Assetic\Filter\FilterInterface $filter */
256                     $filter = $reflection->newInstanceArgs($filterParams);
257                     $assets->ensureFilter($filter);
258                 }
259             }
260         }
261 
262         return $assets;
263     }
264 }
265