Completed
Push — master ( 5ff4ab...0cf65a )
by Alexander
16s queued 10s
created

AssetConverter::isOutdated()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 17
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 5

Importance

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

184
        $command = strtr(/** @scrutinizer ignore-type */ $command, [
Loading history...
185 4
            '{from}' => escapeshellarg("$basePath/$asset"),
186 4
            '{to}'   => escapeshellarg("$basePath/$result"),
187
        ]);
188
        $descriptors = [
189 4
            1 => ['pipe', 'w'],
190
            2 => ['pipe', 'w'],
191
        ];
192 4
        $pipes = [];
193 4
        $proc = proc_open($command, $descriptors, $pipes, $basePath);
194 4
        $stdout = stream_get_contents($pipes[1]);
195 4
        $stderr = stream_get_contents($pipes[2]);
196
197 4
        foreach ($pipes as $pipe) {
198 4
            fclose($pipe);
199
        }
200
201 4
        $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

201
        $status = proc_close(/** @scrutinizer ignore-type */ $proc);
Loading history...
202
203 4
        if ($status === 0) {
204 4
            $this->logger->debug("Converted $asset into $result:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr", [__METHOD__]);
205
        } else {
206
            $this->logger->error("AssetConverter command '$command' failed with exit code $status:\nSTDOUT:\n$stdout\nSTDERR:\n$stderr", [__METHOD__]);
207
        }
208
209 4
        return $status === 0;
210
    }
211
}
212