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
12namespace ComponentInstaller\Process;
13
14use Assetic\Asset\StringAsset;
15use Composer\Config;
16use Composer\Json\JsonFile;
17use Assetic\Asset\AssetCollection;
18use Assetic\Asset\FileAsset;
19
20/**
21 * Builds the require.js configuration.
22 */
23class 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
199var components = $js;
200if (typeof require !== "undefined" && require.config) {
201    require.config(components);
202} else {
203    var require = components;
204}
205if (typeof exports !== "undefined" && typeof module !== "undefined") {
206    module.exports = components;
207}
208EOT;
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