Passed
Push — master ( fb8c0b...38342c )
by Alexander
02:24
created

AssetConverter   A

Complexity

Total Complexity 20

Size/Duplication

Total Lines 274
Duplicated Lines 0 %

Test Coverage

Coverage 95.65%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 75
c 2
b 0
f 0
dl 0
loc 274
ccs 66
cts 69
cp 0.9565
rs 10
wmc 20

8 Methods

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

263
        $status = proc_close(/** @scrutinizer ignore-type */ $proc);
Loading history...
264
265 5
        if ($status === 0) {
266 5
            $this->logger->debug(
267 5
                "Converted $asset into $result:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr",
268 5
                [__METHOD__]
269
            );
270
        } else {
271
            $this->logger->error(
272
                "AssetConverter command '$command' failed with exit code $status:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr",
273
                [__METHOD__]
274
            );
275
        }
276
277 5
        return $status === 0;
278
    }
279
280 11
    private function buildConverterOptions(string $srcExt, array $options): string
281
    {
282 11
        $command = '';
283 11
        $commandOptions = '';
284
285 11
        if (isset($options[$srcExt])) {
286 1
            if (array_key_exists('command', $options[$srcExt])) {
287 1
                $command .= $options[$srcExt]['command'] . ' ';
288
            }
289
290 1
            if (array_key_exists('path', $options[$srcExt])) {
291 1
                $path = $this->aliases->get($options[$srcExt]['path']);
292
293 1
                $commandOptions = strtr($command, [
294 1
                    '{path}' => $path,
295
                ]);
296
            }
297
        }
298
299 11
        return $commandOptions;
300
    }
301
}
302