Passed
Push — master ( 5a3114...9212c9 )
by Alexander
06:49
created

AssetConverter::setCommand()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

245
        $status = proc_close(/** @scrutinizer ignore-type */ $proc);
Loading history...
246
247
        if ($status === 0) {
248
            $this->logger->debug("Converted $asset into $result:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr", [__METHOD__]);
249
        } else {
250
            $this->logger->error("AssetConverter command '$command' failed with exit code $status:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr", [__METHOD__]);
251
        }
252
253
        return $status === 0;
254
    }
255
}
256