Passed
Push — master ( f73c85...40da57 )
by Alexander
02:09
created

AssetConverter::setForceConvert()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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

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