Passed
Pull Request — master (#50)
by Alexander
02:52
created

AssetConverter   A

Complexity

Total Complexity 15

Size/Duplication

Total Lines 191
Duplicated Lines 0 %

Test Coverage

Coverage 92.86%

Importance

Changes 1
Bugs 0 Features 1
Metric Value
wmc 15
eloc 53
c 1
b 0
f 1
dl 0
loc 191
ccs 39
cts 42
cp 0.9286
rs 10

4 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
A convert() 0 17 5
A runCommand() 0 32 4
A isOutdated() 0 16 5
1
<?php
2
declare(strict_types = 1);
3
4
namespace Yiisoft\Asset;
5
6
use Psr\Log\LoggerInterface;
7
use Yiisoft\Aliases\Aliases;
8
9
/**
10
 * AssetConverter supports conversion of several popular script formats into JS or CSS scripts.
11
 *
12
 * It is used by [[AssetManager]] to convert files after they have been published.
13
 */
14
class AssetConverter implements AssetConverterInterface
15
{
16
    /**
17
     * Aliases component.
18
     *
19
     * @var Aliases $aliase
20
     */
21
    private $aliases;
22
23
    /**
24
     * @var array the commands that are used to perform the asset conversion.
25
     * The keys are the asset file extension names, and the values are the corresponding
26
     * target script types (either "css" or "js") and the commands used for the conversion.
27
     *
28
     * You may also use a [path alias](guide:concept-aliases) to specify the location of the command:
29
     *
30
     * ```php
31
     * [
32
     *     'styl' => ['css', '@app/node_modules/bin/stylus < {from} > {to}'],
33
     * ]
34
     * ```
35
     */
36
    public $commands = [
37
        'less'   => ['css', 'lessc {from} {to} --no-color --source-map'],
38
        'scss'   => ['css', 'sass {from} {to} --sourcemap'],
39
        'sass'   => ['css', 'sass {from} {to} --sourcemap'],
40
        'styl'   => ['css', 'stylus < {from} > {to}'],
41
        'coffee' => ['js', 'coffee -p {from} > {to}'],
42
        'ts'     => ['js', 'tsc --out {to} {from}'],
43
    ];
44
45
    /**
46
     * @var bool whether the source asset file should be converted even if its result already exists.
47
     * You may want to set this to be `true` during the development stage to make sure the converted
48
     * assets are always up-to-date. Do not set this to true on production servers as it will
49
     * significantly degrade the performance.
50
     */
51
    public $forceConvert = false;
52
53
    /**
54
     * @var callable a PHP callback, which should be invoked to check whether asset conversion result is outdated.
55
     * It will be invoked only if conversion target file exists and its modification time is older then the one of source file.
56
     * Callback should match following signature:
57
     *
58
     * ```php
59
     * function (string $basePath, string $sourceFile, string $targetFile, string $sourceExtension, string $targetExtension) : bool
60
     * ```
61
     *
62
     * where $basePath is the asset source directory; $sourceFile is the asset source file path, relative to $basePath;
63
     * $targetFile is the asset target file path, relative to $basePath; $sourceExtension is the source asset file extension
64
     * and $targetExtension is the target asset file extension, respectively.
65
     *
66
     * It should return `true` is case asset should be reconverted.
67
     * For example:
68
     *
69
     * ```php
70
     * function ($basePath, $sourceFile, $targetFile, $sourceExtension, $targetExtension) {
71
     *     if (YII_ENV !== 'dev') {
72
     *         return false;
73
     *     }
74
     *
75
     *     $resultModificationTime = @filemtime("$basePath/$result");
76
     *     foreach (FileHelper::findFiles($basePath, ['only' => ["*.{$sourceExtension}"]]) as $filename) {
77
     *         if ($resultModificationTime < @filemtime($filename)) {
78
     *             return true;
79
     *         }
80
     *     }
81
     *
82
     *     return false;
83
     * }
84
     * ```
85
     */
86
    public $isOutdatedCallback;
87
88
    /**
89
     * @var LoggerInterface $logger
90
     */
91
    private $logger;
92
93
    /**
94
     * AssetConverter constructor.
95
     *
96
     * @param Aliases $aliases
97
     */
98 17
    public function __construct(Aliases $aliases, LoggerInterface $logger)
99
    {
100 17
        $this->aliases = $aliases;
101 17
        $this->logger = $logger;
102
    }
103
104
    /**
105
     * Converts a given asset file into a CSS or JS file.
106
     *
107
     * @param string $asset the asset file path, relative to $basePath
108
     * @param string $basePath the directory the $asset is relative to.
109
     *
110
     * @return string the converted asset file path, relative to $basePath.
111
     */
112 17
    public function convert(string $asset, string $basePath): string
113
    {
114 17
        $pos = strrpos($asset, '.');
115 17
        if ($pos !== false) {
116 17
            $srcExt = substr($asset, $pos + 1);
117 17
            if (isset($this->commands[$srcExt])) {
118 4
                [$ext, $command] = $this->commands[$srcExt];
119 4
                $result = substr($asset, 0, $pos + 1).$ext;
120 4
                if ($this->forceConvert || $this->isOutdated($basePath, $asset, $result, $srcExt, $ext)) {
121 4
                    $this->runCommand($command, $basePath, $asset, $result);
122
                }
123
124 4
                return $result;
125
            }
126
        }
127
128 13
        return $asset;
129
    }
130
131
    /**
132
     * Checks whether asset convert result is outdated, and thus should be reconverted.
133
     *
134
     * @param string $basePath the directory the $asset is relative to.
135
     * @param string $sourceFile the asset source file path, relative to [[$basePath]].
136
     * @param string $targetFile the converted asset file path, relative to [[$basePath]].
137
     * @param string $sourceExtension source asset file extension.
138
     * @param string $targetExtension target asset file extension.
139
     *
140
     * @return bool whether asset is outdated or not.
141
     */
142 4
    protected function isOutdated(string $basePath, string $sourceFile, string $targetFile, string $sourceExtension, string $targetExtension): bool
143
    {
144 4
        $resultModificationTime = @filemtime("$basePath/$targetFile");
145 4
        if ($resultModificationTime === false || $resultModificationTime === null) {
146 4
            return true;
147
        }
148
149 3
        if ($resultModificationTime < @filemtime("$basePath/$sourceFile")) {
150 1
            return true;
151
        }
152
153 3
        if ($this->isOutdatedCallback === null) {
154 2
            return false;
155
        }
156
157 1
        return call_user_func($this->isOutdatedCallback, $basePath, $sourceFile, $targetFile, $sourceExtension, $targetExtension);
158
    }
159
160
    /**
161
     * Runs a command to convert asset files.
162
     *
163
     * @param string $command the command to run. If prefixed with an `@` it will be treated as a [path alias](guide:concept-aliases).
164
     * @param string $basePath asset base path and command working directory
165
     * @param string $asset the name of the asset file
166
     * @param string $result the name of the file to be generated by the converter command
167
     *
168
     * @throws \Exception when the command fails and YII_DEBUG is true.
169
     * In production mode the error will be logged.
170
     *
171
     * @return bool true on success, false on failure. Failures will be logged.
172
     */
173 4
    protected function runCommand(string $command, string $basePath, string $asset, string $result): bool
174
    {
175 4
        $command = $this->aliases->get($command);
176
177 4
        $command = strtr($command, [
0 ignored issues
show
Bug introduced by
It seems like $command can also be of type boolean; however, parameter $str of strtr() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

177
        $command = strtr(/** @scrutinizer ignore-type */ $command, [
Loading history...
178 4
            '{from}' => escapeshellarg("$basePath/$asset"),
179 4
            '{to}'   => escapeshellarg("$basePath/$result"),
180
        ]);
181
        $descriptor = [
182 4
            1 => ['pipe', 'w'],
183
            2 => ['pipe', 'w'],
184
        ];
185 4
        $pipes = [];
186 4
        $proc = proc_open($command, $descriptor, $pipes, $basePath);
187 4
        $stdout = stream_get_contents($pipes[1]);
188 4
        $stderr = stream_get_contents($pipes[2]);
189
190 4
        foreach ($pipes as $pipe) {
191 4
            fclose($pipe);
192
        }
193
194 4
        $status = proc_close($proc);
0 ignored issues
show
Bug introduced by
It seems like $proc can also be of type false; however, parameter $process of proc_close() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

194
        $status = proc_close(/** @scrutinizer ignore-type */ $proc);
Loading history...
195
196 4
        if ($status === 0) {
197 4
            $this->logger->debug("Converted $asset into $result:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr", [__METHOD__]);
198
        } elseif (YII_DEBUG) {
0 ignored issues
show
Bug introduced by
The constant Yiisoft\Asset\YII_DEBUG was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
199
            throw new \RuntimeException("AssetConverter command '$command' failed with exit code $status:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr");
200
        } else {
201
            $this->logger->error("AssetConverter command '$command' failed with exit code $status:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr", [__METHOD__]);
202
        }
203
204 4
        return $status === 0;
205
    }
206
}
207