Passed
Push — master ( 7525e3...97ef2a )
by Alexander
13:01 queued 11:22
created

AssetConverter::buildConverterOptions()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 10
nc 5
nop 2
dl 0
loc 20
ccs 11
cts 11
cp 1
crap 4
rs 9.9332
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Assets;
6
7
use Psr\Log\LoggerInterface;
8
use Yiisoft\Aliases\Aliases;
9
10
use function array_key_exists;
11
12
/**
13
 * AssetConverter supports conversion of several popular script formats into JavaScript or CSS.
14
 *
15
 * It is used by {@see AssetManager} to convert files after they have been published.
16
 */
17
final class AssetConverter implements AssetConverterInterface
18
{
19
    /**
20
     * Aliases component
21
     */
22
    private Aliases $aliases;
23
24
    /**
25
     * @var array the commands that are used to perform the asset conversion.
26
     * The keys are the asset file extension names, and the values are the corresponding
27
     * target script types (either "css" or "js") and the commands used for the conversion.
28
     *
29
     * You may also use a [path alias](guide:concept-aliases) to specify the location of the command:
30
     *
31
     * ```php
32
     * [
33
     *     'styl' => ['css', '@app/node_modules/bin/stylus < {from} > {to}'],
34
     * ]
35
     * ```
36
     */
37
    private array $commands = [
38
        'less'   => ['css', 'lessc {from} {to} --no-color --source-map'],
39
        'scss'   => ['css', 'sass {options} {from} {to}'],
40
        'sass'   => ['css', 'sass {options} {from} {to}'],
41
        'styl'   => ['css', 'stylus < {from} > {to}'],
42
        'coffee' => ['js', 'coffee -p {from} > {to}'],
43
        'ts'     => ['js', 'tsc --out {to} {from}'],
44
    ];
45
46
    /**
47
     * @var bool whether the source asset file should be converted even if its result already exists.
48
     * You may want to set this to be `true` during the development stage to make sure the converted
49
     * assets are always up-to-date. Do not set this to true on production servers as it will
50
     * significantly degrade the performance.
51
     */
52
    private bool $forceConvert = false;
53
54
    /**
55
     * @var callable a PHP callback, which should be invoked to check whether asset conversion result is outdated.
56
     * It will be invoked only if conversion target file exists and its modification time is older then the one of
57
     * source file.
58
     * Callback should match following signature:
59
     *
60
     * ```php
61
     * function (string $basePath, string $sourceFile, string $targetFile, string $sourceExtension, string $targetExtension) : bool
62
     * ```
63
     *
64
     * where $basePath is the asset source directory; $sourceFile is the asset source file path, relative to $basePath;
65
     * $targetFile is the asset target file path, relative to $basePath; $sourceExtension is the source asset file
66
     * extension and $targetExtension is the target asset file extension, respectively.
67
     *
68
     * It should return `true` is case asset should be reconverted.
69
     * For example:
70
     *
71
     * ```php
72
     * function ($basePath, $sourceFile, $targetFile, $sourceExtension, $targetExtension) {
73
     *     if (YII_ENV !== 'dev') {
74
     *         return false;
75
     *     }
76
     *
77
     *     $resultModificationTime = @filemtime("$basePath/$result");
78
     *     foreach (FileHelper::findFiles($basePath, ['only' => ["*.{$sourceExtension}"]]) as $filename) {
79
     *         if ($resultModificationTime < @filemtime($filename)) {
80
     *             return true;
81
     *         }
82
     *     }
83
     *
84
     *     return false;
85
     * }
86
     * ```
87
     */
88
    private $isOutdatedCallback;
89
90
    private LoggerInterface $logger;
91
92 59
    public function __construct(Aliases $aliases, LoggerInterface $logger)
93
    {
94 59
        $this->aliases = $aliases;
95 59
        $this->logger = $logger;
96 59
    }
97
98
    /**
99
     * Converts a given asset file into a CSS or JS file.
100
     *
101
     * @param string $asset the asset file path, relative to $basePath
102
     * @param string $basePath the directory the $asset is relative to.
103
     * @param array $options additional options to pass to {@see AssetConverter::runCommand}
104
     *
105
     * @return string the converted asset file path, relative to $basePath.
106
     */
107 11
    public function convert(string $asset, string $basePath, array $options = []): string
108
    {
109 11
        $pos = strrpos($asset, '.');
110
111 11
        if ($pos !== false) {
112 11
            $srcExt = substr($asset, $pos + 1);
113
114 11
            $commandOptions = $this->buildConverterOptions($srcExt, $options);
115
116 11
            if (isset($this->commands[$srcExt])) {
117 5
                [$ext, $command] = $this->commands[$srcExt];
118 5
                $result = substr($asset, 0, $pos + 1) . $ext;
119 5
                if ($this->forceConvert || $this->isOutdated($basePath, $asset, $result, $srcExt, $ext)) {
120 5
                    $this->runCommand($command, $basePath, $asset, $result, $commandOptions);
121
                }
122
123 5
                return $result;
124
            }
125
        }
126
127 6
        return $asset;
128
    }
129
130
    /**
131
     * Allows you to set a command that is used to perform the asset conversion.
132
     *
133
     * @param string $from file extension of the format converting from
134
     * @param string $to file extension of the format converting to
135
     * @param string $command command to execute for conversion
136
     *
137
     * Example:
138
     *
139
     * $converter->setCommand('scss', 'css', 'sass {options} {from} {to}');
140
     *
141
     * @return void
142
     */
143 5
    public function setCommand(string $from, string $to, string $command): void
144
    {
145 5
        $this->commands[$from] = [$to, $command];
146 5
    }
147
148
    /**
149
     * Make the conversion regardless of whether the asset already exists.
150
     *
151
     * @param bool $value
152
     * @return void
153
     */
154 59
    public function setForceConvert(bool $value): void
155
    {
156 59
        $this->forceConvert = $value;
157 59
    }
158
159
    /**
160
     * PHP callback, which should be invoked to check whether asset conversion result is outdated.
161
     *
162
     * @param callable $value
163
     *
164
     * @return void
165
     */
166 1
    public function setIsOutdatedCallback(callable $value): void
167
    {
168 1
        $this->isOutdatedCallback = $value;
169 1
    }
170
171
    /**
172
     * Checks whether asset convert result is outdated, and thus should be reconverted.
173
     *
174
     * @param string $basePath the directory the $asset is relative to.
175
     * @param string $sourceFile the asset source file path, relative to [[$basePath]].
176
     * @param string $targetFile the converted asset file path, relative to [[$basePath]].
177
     * @param string $sourceExtension source asset file extension.
178
     * @param string $targetExtension target asset file extension.
179
     *
180
     * @return bool whether asset is outdated or not.
181
     */
182 5
    private function isOutdated(string $basePath, string $sourceFile, string $targetFile, string $sourceExtension, string $targetExtension): bool
183
    {
184 5
        $resultModificationTime = @filemtime("$basePath/$targetFile");
185
186 5
        if ($resultModificationTime === false || $resultModificationTime === null) {
187 5
            return true;
188
        }
189
190 3
        if ($resultModificationTime < @filemtime("$basePath/$sourceFile")) {
191 1
            return true;
192
        }
193
194 3
        if ($this->isOutdatedCallback === null) {
195 2
            return false;
196
        }
197
198 1
        return \call_user_func($this->isOutdatedCallback, $basePath, $sourceFile, $targetFile, $sourceExtension, $targetExtension);
199
    }
200
201
    /**
202
     * Runs a command to convert asset files.
203
     *
204
     * @param string $command the command to run. If prefixed with an `@` it will be treated as a
205
     * [path alias](guide:concept-aliases).
206
     * @param string $basePath asset base path and command working directory
207
     * @param string $asset the name of the asset file
208
     * @param string $result the name of the file to be generated by the converter command
209
     *
210
     * @throws \Exception when the command fails and YII_DEBUG is true.
211
     * In production mode the error will be logged.
212
     *
213
     * @return bool true on success, false on failure. Failures will be logged.
214
     */
215 5
    private function runCommand(string $command, string $basePath, string $asset, string $result, ?string $options = null): bool
216
    {
217 5
        $basePath = $this->aliases->get($basePath);
218
219 5
        $command = $this->aliases->get($command);
220
221 5
        $command = strtr($command, [
222 5
            '{options}' => $options,
223 5
            '{from}' => escapeshellarg("$basePath/$asset"),
224 5
            '{to}'   => escapeshellarg("$basePath/$result"),
225
        ]);
226
227
        $descriptors = [
228 5
            1 => ['pipe', 'w'],
229
            2 => ['pipe', 'w'],
230
        ];
231
232 5
        $pipes = [];
233
234 5
        $proc = proc_open($command, $descriptors, $pipes, $basePath);
235
236 5
        $stdout = stream_get_contents($pipes[1]);
237
238 5
        $stderr = stream_get_contents($pipes[2]);
239
240 5
        foreach ($pipes as $pipe) {
241 5
            fclose($pipe);
242
        }
243
244 5
        $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

244
        $status = proc_close(/** @scrutinizer ignore-type */ $proc);
Loading history...
245
246 5
        if ($status === 0) {
247 5
            $this->logger->debug("Converted $asset into $result:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr", [__METHOD__]);
248
        } else {
249
            $this->logger->error("AssetConverter command '$command' failed with exit code $status:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr", [__METHOD__]);
250
        }
251
252 5
        return $status === 0;
253
    }
254
255 11
    private function buildConverterOptions(string $srcExt, array $options): string
256
    {
257 11
        $command = '';
258 11
        $commandOptions = '';
259
260 11
        if (isset($options[$srcExt])) {
261 1
            if (array_key_exists('command', $options[$srcExt])) {
262 1
                $command .= $options[$srcExt]['command'] . ' ';
263
            }
264
265 1
            if (array_key_exists('path', $options[$srcExt])) {
266 1
                $path = $this->aliases->get($options[$srcExt]['path']);
267
268 1
                $commandOptions = strtr($command, [
269 1
                    '{path}' => $path
270
                ]);
271
            }
272
        }
273
274 11
        return $commandOptions;
275
    }
276
}
277