AssetConverter   A
last analyzed

Complexity

Total Complexity 20

Size/Duplication

Total Lines 285
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 20
eloc 76
c 2
b 0
f 0
dl 0
loc 285
rs 10
ccs 81
cts 81
cp 1

8 Methods

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