Completed
Pull Request — master (#11)
by Michael
07:46
created

StaticsMergerPlugin::getYarnExecutablePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 0
cts 2
cp 0
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 0
crap 2
1
<?php
2
declare(strict_types = 1);
3
4
namespace Jh\StaticsMerger;
5
6
use Composer\Composer;
7
use Composer\EventDispatcher\EventSubscriberInterface;
8
use Composer\IO\IOInterface;
9
use Composer\Plugin\PluginInterface;
10
use Composer\Script\ScriptEvents;
11
use Composer\Util\Filesystem;
12
use Composer\Package\PackageInterface;
13
use Symfony\Component\Process\Exception\LogicException;
14
use Symfony\Component\Process\Exception\ProcessFailedException;
15
use Symfony\Component\Process\Exception\RuntimeException;
16
use Symfony\Component\Process\ExecutableFinder;
17
use Symfony\Component\Process\Process;
18
19
/**
20
 * Composer Plugin for merging static assets with the Jh Magento Skeleton
21
 * @author Michael Woodward <[email protected]>
22
 */
23
class StaticsMergerPlugin implements PluginInterface, EventSubscriberInterface
24
{
25
    /**
26
     * Package Type to install
27
     */
28
    const PACKAGE_TYPE = 'static';
29
30
    /**
31
     * @var Composer $composer
32
     */
33
    protected $composer;
34
35
    /**
36
     * @var IOInterface $io
37
     */
38
    protected $io;
39
40
    /**
41
     * @var Filesystem
42
     */
43
    protected $filesystem;
44
45
    /**
46
     * @var string
47
     */
48
    protected $vendorDir;
49
50
    /**
51
     * @var array
52
     */
53
    protected $packageExtra = [];
54
55
    /**
56
     * @var array
57
     */
58
    protected $staticMaps = [];
59
60
    /**
61
     * @var string
62
     */
63
    protected $mageDir = '';
64
65
    /**
66
     * @throws \RuntimeException On composer config failure
67
     */
68 24
    public function activate(Composer $composer, IOInterface $io) : bool
69
    {
70 24
        $this->composer     = $composer;
71 24
        $this->io           = $io;
72 24
        $this->vendorDir    = rtrim($composer->getConfig()->get('vendor-dir'), '/');
73 24
        $this->filesystem   = new Filesystem();
74 24
        $this->packageExtra = $this->composer->getPackage()->getExtra();
75
76 24
        if (!array_key_exists('static-map', $this->packageExtra)) {
77 1
            $this->io->write('<info>No static maps defined</info>');
78 1
            return false;
79
        }
80
81 23
        if (!array_key_exists('magento-root-dir', $this->packageExtra)) {
82 1
            $this->io->write('<info>Magento root dir not defined, assumed current working directory</info>');
83
        } else {
84 22
            $this->mageDir = rtrim($this->packageExtra['magento-root-dir'], '/');
85
        }
86
87 23
        $this->staticMaps  = $this->packageExtra['static-map'];
88 23
        return true;
89
    }
90
91 16
    public function getInstallPath(PackageInterface $package) : string
92
    {
93 16
        $targetDir = $package->getTargetDir();
94
95 16
        return $this->getPackageBasePath($package) . ($targetDir ? '/'.$targetDir : '');
96
    }
97
98 16
    protected function getPackageBasePath(PackageInterface $package) : string
99
    {
100 16
        $this->filesystem->ensureDirectoryExists($this->vendorDir);
101 16
        $this->vendorDir = realpath($this->vendorDir);
102
103 16
        return ($this->vendorDir ? $this->vendorDir.'/' : '') . $package->getPrettyName();
104
    }
105
106 1
    public static function getSubscribedEvents() : array
107
    {
108
        return [
109 1
            ScriptEvents::PRE_INSTALL_CMD => [
110
                ['verifyEnvironment', 1],
111
                ['staticsCleanup', 0]
112 1
            ],
113 1
            ScriptEvents::PRE_UPDATE_CMD => [
114
                ['verifyEnvironment', 1],
115
                ['staticsCleanup', 0]
116
            ],
117 1
            ScriptEvents::POST_INSTALL_CMD => [
118
                ['staticsCompile', 1],
119
                ['symlinkStatics', 0]
120
            ],
121 1
            ScriptEvents::POST_UPDATE_CMD => [
122
                ['staticsCompile', 1],
123
                ['symlinkStatics', 0]
124
            ]
125
        ];
126
    }
127
128
    public function verifyEnvironment() : bool
129
    {
130
        return is_executable($this->getYarnExecutablePath());
131
    }
132
133
    private function getYarnExecutablePath() : string
134
    {
135
        return (new ExecutableFinder)->find('yarn', '');
136
    }
137
138
    /**
139
     * @throws \RuntimeException When Yarn install fails or crossbow fails
140
     * @throws LogicException From process
141
     * @throws RuntimeException From process
142
     */
143
    public function staticsCompile()
144
    {
145
        $cwd = getcwd();
146
147
        foreach ($this->getStaticPackages() as $package) {
148
            chdir($this->getInstallPath($package));
149
150
            $this->io->write(sprintf('<info>Installing dependencies for "%s"', $package->getPrettyName()));
151
            $dependencyProcess = new Process($this->getYarnExecutablePath());
152
153
            try {
154
                $dependencyProcess->mustRun();
155
            } catch (ProcessFailedException $e) {
156
                $this->io->write($dependencyProcess->getOutput());
157
                $this->io->write(
158
                    sprintf('<error>Failed to install dependencies for "%s" </error>', $package->getPrettyName())
159
                );
160
                return false;
161
            }
162
163
            $this->io->write(sprintf('<info>Building statics assets for "%s"', $package->getPrettyName()));
164
            $buildProcess = new Process('node_modules/.bin/cb release');
165
166
            try {
167
                $buildProcess->mustRun();
168
            } catch (ProcessFailedException $e) {
169
                $this->io->write($buildProcess->getOutput());
170
                $this->io->write(
171
                    sprintf('<error>Static package "%s" failed to build </error>', $package->getPrettyName())
172
                );
173
                return false;
174
            }
175
        }
176
177
        chdir($cwd);
178
    }
179
180 17
    public function symlinkStatics()
181
    {
182 17
        foreach ($this->getStaticPackages() as $package) {
183 16
            $packageSource = $this->getInstallPath($package);
184
185 16
            foreach ($this->getStaticMaps($package->getName()) as $mappingDir => $mappings) {
186 16
                $destinationTheme = $this->getRootThemeDir($mappingDir);
187
188
                // Add slash to paths
189 16
                $packageSource    = rtrim($packageSource, '/');
190 16
                $destinationTheme = rtrim($destinationTheme, '/');
191
192
                // If theme doesn't exist - Create it
193 16
                $this->filesystem->ensureDirectoryExists($destinationTheme);
194
195
                // Process files from package
196 16
                if ($mappings) {
197 15
                    $this->processFiles($packageSource, $destinationTheme, $mappings);
198
                } else {
199 1
                    $this->io->write(
200
                        sprintf(
201 1
                            '<error>%s requires at least one file mapping, has none!<error>',
202 16
                            $package->getPrettyName()
203
                        )
204
                    );
205
                }
206
            }
207
        }
208 17
    }
209
210
    /**
211
     * Processes defined file mappings and symlinks resulting files to destination theme
212
     */
213 15
    public function processFiles(string $packageSource, string $destinationTheme, array $files = [])
214
    {
215 15
        foreach ($files as $file) {
216
            // Ensure we have correct json
217 15
            if (isset($file['src']) && isset($file['dest'])) {
218 15
                $src    = sprintf("%s/%s", $packageSource, $file['src']);
219 15
                $dest   = rtrim($file['dest'], '/');
220
221
                // Check if it's a glob
222 15
                if (strpos($src, '*') !== false) {
223 2
                    $files = array_filter(glob($src), 'is_file');
224 2
                    foreach ($files as $globFile) {
225
                        //strip the full path
226
                        //and just get path relative to package
227 2
                        $fileSource = str_replace(sprintf("%s/", $packageSource), "", $globFile);
228
229 2
                        $dest = ltrim(sprintf("%s/%s", $dest, basename($fileSource)), '/');
230
231 2
                        $this->processSymlink($packageSource, $fileSource, $destinationTheme, $dest);
232 2
                        $dest = $file['dest'];
233
                    }
234
                } else {
235 15
                    if (!$dest) {
236 1
                        $this->io->write(
237 1
                            sprintf('<error>Full path is required for: "%s" </error>', $file['src'])
238
                        );
239 1
                        return false;
240
                    }
241
242 15
                    $this->processSymlink($packageSource, $file['src'], $destinationTheme, $dest);
243
                }
244
            }
245
        }
246 14
    }
247
248
    /**
249
     * Process symlink, checks given source and destination paths
250
     */
251 15
    public function processSymlink(
252
        string $packageSrc,
253
        string $relativeSourcePath,
254
        string $destinationTheme,
255
        string $relativeDestinationPath
256
    ) {
257 15
        $sourcePath         = sprintf("%s/%s", $packageSrc, $relativeSourcePath);
258 15
        $destinationPath    = sprintf("%s/%s", $destinationTheme, $relativeDestinationPath);
259
260 15
        if (!file_exists($sourcePath)) {
261 1
            $this->io->write(
262 1
                sprintf('<error>The static package does not contain directory: "%s" </error>', $relativeSourcePath)
263
            );
264 1
            return;
265
        }
266
267 14
        if (file_exists($destinationPath) && !is_link($destinationPath)) {
268 1
            $this->io->write(
269
                sprintf(
270 1
                    '<error>Your static path: "%s" is currently not a symlink, please remove first </error>',
271
                    $destinationPath
272
                )
273
            );
274 1
            return;
275
        }
276
277
        //if it's a link, remove it and recreate it
278
        //assume we are updating the static package
279 13
        if (is_link($destinationPath)) {
280 1
            unlink($destinationPath);
281
        } else {
282
            //file doesn't already exist
283
            //lets make sure the parent directory does
284 13
            $this->filesystem->ensureDirectoryExists(dirname($destinationPath));
285
        }
286
287 13
        $relativeSourcePath = $this->getRelativePath($destinationPath, $sourcePath);
288 13
        if (!@\symlink($relativeSourcePath, $destinationPath)) {
289 1
            $this->io->write(sprintf('<error>Failed to symlink %s to %s</error>', $sourcePath, $destinationPath));
290
        }
291 13
    }
292
293
    /**
294
     * Get filtered packages array
295
     */
296 19
    public function getStaticPackages() : array
297
    {
298 19
        $packages = $this->composer->getRepositoryManager()->getLocalRepository()->getPackages();
299
300
        return array_filter($packages, function (PackageInterface $package) {
301 19
            return $package->getType() == static::PACKAGE_TYPE && $this->getStaticMaps($package->getName());
302 19
        });
303
    }
304
305
    /**
306
     * Get a single static package's maps or all static maps
307
     */
308 21
    public function getStaticMaps($packageName = null) : array
309
    {
310 21
        if ($packageName === null) {
311 1
            return $this->staticMaps;
312 20
        } elseif (array_key_exists($packageName, $this->staticMaps)) {
313 19
            return $this->staticMaps[$packageName];
314
        } else {
315 1
            $this->io->write(sprintf('<error>Mappings for %s are not defined</error>', $packageName));
316 1
            return [];
317
        }
318
    }
319
320
    /**
321
     * Isolated event that runs on PRE hooks to cleanup mapped packages
322
     */
323 6
    public function staticsCleanup()
324
    {
325 6
        foreach ($this->getStaticPackages() as $package) {
326 6
            foreach ($this->getStaticMaps($package->getName()) as $mappingDir => $mappings) {
327 6
                $themeRootDir = $this->getRootThemeDir($mappingDir);
328
329 6
                if (!is_dir($themeRootDir)) {
330 1
                    continue;
331
                }
332
333
                // Get contents and sort
334 5
                $contents   = $this->getFullDirectoryListing($themeRootDir);
335 5
                $strLengths = array_map('strlen', $contents);
336 5
                array_multisort($strLengths, SORT_DESC, $contents);
337
338
                // Exception error message
339 5
                $errorMsg = sprintf("<error>Failed to remove %s from %s</error>", $package->getName(), $themeRootDir);
340
341 5
                foreach ($contents as $content) {
342
                    // Remove packages symlinked files/dirs
343 5
                    if (is_link($content)) {
344 5
                        $this->tryCleanup($content, $errorMsg);
345 5
                        continue;
346
                    }
347
348
                    // Remove empty folders
349 5
                    if (is_dir($content) && $this->filesystem->isDirEmpty($content)) {
350 6
                        $this->tryCleanup($content, $errorMsg);
351
                    }
352
                }
353
            }
354
        }
355 6
    }
356
357
    /**
358
     * Try to cleanup a file/dir, output on exception
359
     */
360 5
    private function tryCleanup(string $path, string $errorMsg)
361
    {
362
        try {
363 5
            $this->filesystem->remove($path);
364 2
        } catch (\RuntimeException $ex) {
365 2
            $this->io->write($errorMsg);
366
        }
367 5
    }
368
369
    /**
370
     * Get full directory listing without dots
371
     */
372 5
    private function getFullDirectoryListing(string $path) : array
373
    {
374 5
        $listings   = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path));
375 5
        $listingArr = array_keys(\iterator_to_array($listings));
376
377
        // Remove dots :)
378 5
        $listingArr = array_map(function ($listing) {
379 5
            return rtrim($listing, '\/\.');
380 5
        }, $listingArr);
381
382 5
        return array_unique($listingArr);
383
    }
384
385
    /**
386
     * This is utility method for symlink creation.
387
     * @see http://stackoverflow.com/a/2638272/485589
388
     */
389 16
    public function getRelativePath(string $from, string $to) : string
390
    {
391
        // some compatibility fixes for Windows paths
392 16
        $from = is_dir($from) ? rtrim($from, '\/') . '/' : $from;
393 16
        $to   = is_dir($to) ? rtrim($to, '\/') . '/' : $to;
394 16
        $from = str_replace('\\', '/', $from);
395 16
        $to   = str_replace('\\', '/', $to);
396
397 16
        $from     = explode('/', $from);
398 16
        $to       = explode('/', $to);
399 16
        $relPath  = $to;
400
401 16
        foreach ($from as $depth => $dir) {
402
            // find first non-matching dir
403 16
            if ($dir === $to[$depth]) {
404
                // ignore this directory
405 16
                array_shift($relPath);
406
            } else {
407
                // get number of remaining dirs to $from
408 15
                $remaining = count($from) - $depth;
409 15
                if ($remaining > 1) {
410
                    // add traversals up to first matching dir
411 14
                    $padLength = (count($relPath) + $remaining - 1) * -1;
412 14
                    $relPath = array_pad($relPath, $padLength, '..');
413 14
                    break;
414
                } else {
415 16
                    $relPath[0] = './' . $relPath[0];
416
                }
417
            }
418
        }
419 16
        return implode('/', $relPath);
420
    }
421
422 17
    private function getRootThemeDir(string $mappingDir) : string
423
    {
424 17
        return sprintf(
425 17
            '%s%s/app/design/frontend/%s/web',
426
            getcwd(),
427 17
            $this->mageDir ? '/' . $this->mageDir : '',
428
            ucwords($mappingDir)
429
        );
430
    }
431
}
432