Passed
Push — master ( 089267...d7842f )
by Alexander
03:29 queued 12s
created

AssetManager::withConverter()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 1
dl 0
loc 5
ccs 4
cts 4
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Assets;
6
7
use RuntimeException;
8
use Yiisoft\Aliases\Aliases;
9
use Yiisoft\Assets\Exception\InvalidConfigException;
10
11
use function array_key_exists;
12
use function array_merge;
13
use function array_shift;
14
use function array_unshift;
15
use function in_array;
16
use function is_array;
17
use function is_file;
18
19
/**
20
 * AssetManager manages asset bundle configuration and loading.
21
 */
22
final class AssetManager
23
{
24
    /**
25
     * @var string[] List of names of allowed asset bundles. If the array is empty, then any asset bundles are allowed.
26
     */
27
    private array $allowedBundleNames;
28
29
    /**
30
     * @var array The asset bundle configurations. This property is provided to customize asset bundles.
31
     */
32
    private array $customizedBundles;
33
34
    /**
35
     * @var array AssetBundle[] list of the registered asset bundles.
36
     * The keys are the bundle names, and the values are the registered {@see AssetBundle} objects.
37
     *
38
     * {@see registerAssetBundle()}
39
     */
40
    private array $registeredBundles = [];
41
42
    private array $loadedBundles = [];
43
    private array $dummyBundles = [];
44
    private array $cssFiles = [];
45
    private array $jsFiles = [];
46
    private array $jsStrings = [];
47
    private array $jsVars = [];
48
    private ?AssetConverterInterface $converter = null;
49
    private ?AssetPublisherInterface $publisher = null;
50
    private AssetLoaderInterface $loader;
51
    private Aliases $aliases;
52
53
    /**
54
     * @param Aliases $aliases The aliases instance.
55
     * @param AssetLoaderInterface $loader The loader instance.
56
     * @param string[] $allowedBundleNames List of names of allowed asset bundles. If the array is empty, then any
57
     * asset bundles are allowed. If the names of allowed asset bundles were specified, only these asset bundles
58
     * or their dependencies can be registered {@see register()} and obtained {@see getBundle()}. Also, specifying
59
     * names allows to export {@see export()} asset bundles automatically without first registering them manually.
60
     * @param array $customizedBundles The asset bundle configurations. Provided to customize asset bundles.
61
     * When a bundle is being loaded by {@see getBundle()}, if it has a corresponding configuration specified
62
     * here, the configuration will be applied to the bundle. The array keys are the asset class bundle names
63
     * (without leading backslash). If a value is false, it means the corresponding asset bundle is disabled
64
     * and {@see getBundle()} should return an instance of the specified asset bundle with empty property values.
65
     */
66 90
    public function __construct(
67
        Aliases $aliases,
68
        AssetLoaderInterface $loader,
69
        array $allowedBundleNames = [],
70
        array $customizedBundles = []
71
    ) {
72 90
        $this->aliases = $aliases;
73 90
        $this->loader = $loader;
74 90
        $this->allowedBundleNames = $allowedBundleNames;
75 90
        $this->customizedBundles = $customizedBundles;
76 90
    }
77
78
    /**
79
     * Returns a cloned named asset bundle.
80
     *
81
     * This method will first look for the bundle in {@see $customizedBundles}.
82
     * If not found, it will treat `$name` as the class of the asset bundle and create a new instance of it.
83
     * If `$name` is not a class name, an {@see AssetBundle} instance will be created.
84
     *
85
     * Cloning is used to prevent an asset bundle instance from being modified in a non-context of the asset manager.
86
     *
87
     * @param string $name The class name of the asset bundle (without the leading backslash).
88
     *
89
     * @throws InvalidConfigException For invalid asset bundle configuration.
90
     *
91
     * @return AssetBundle The asset bundle instance.
92
     */
93 9
    public function getBundle(string $name): AssetBundle
94
    {
95 9
        if (!empty($this->allowedBundleNames)) {
96 4
            $this->checkAllowedBundleName($name);
97
        }
98
99 9
        $bundle = $this->loadBundle($name);
100 9
        $bundle = $this->publishBundle($bundle);
101
102 9
        return clone $bundle;
103
    }
104
105
    /**
106
     * Returns the actual URL for the specified asset.
107
     *
108
     * @param string $name The asset bundle name.
109
     * @param string $path The asset path.
110
     *
111
     * @throws InvalidConfigException If asset files are not found.
112
     *
113
     * @return string The actual URL for the specified asset.
114
     */
115 1
    public function getAssetUrl(string $name, string $path): string
116
    {
117 1
        return $this->loader->getAssetUrl($this->getBundle($name), $path);
118
    }
119
120
    /**
121
     * Return config array CSS AssetBundle.
122
     *
123
     * @return array
124
     */
125 16
    public function getCssFiles(): array
126
    {
127 16
        return $this->cssFiles;
128
    }
129
130
    /**
131
     * Returns config array JS AssetBundle.
132
     *
133
     * @return array
134
     */
135 26
    public function getJsFiles(): array
136
    {
137 26
        return $this->jsFiles;
138
    }
139
140
    /**
141
     * Returns JS code blocks.
142
     *
143
     * @return array
144
     */
145 1
    public function getJsStrings(): array
146
    {
147 1
        return $this->jsStrings;
148
    }
149
150
    /**
151
     * Returns JS variables.
152
     *
153
     * @return array
154
     */
155 1
    public function getJsVars(): array
156
    {
157 1
        return $this->jsVars;
158
    }
159
160
    /**
161
     * Returns a new instance with the specified converter.
162
     *
163
     * @param AssetConverterInterface $converter
164
     *
165
     * @return self
166
     */
167 90
    public function withConverter(AssetConverterInterface $converter): self
168
    {
169 90
        $new = clone $this;
170 90
        $new->converter = $converter;
171 90
        return $new;
172
    }
173
174
    /**
175
     * Returns a new instance with the specified loader.
176
     *
177
     * @param AssetLoaderInterface $loader
178
     *
179
     * @return self
180
     */
181 25
    public function withLoader(AssetLoaderInterface $loader): self
182
    {
183 25
        $new = clone $this;
184 25
        $new->loader = $loader;
185 25
        return $new;
186
    }
187
188
    /**
189
     * Returns a new instance with the specified publisher.
190
     *
191
     * @param AssetPublisherInterface $publisher
192
     *
193
     * @return self
194
     */
195 90
    public function withPublisher(AssetPublisherInterface $publisher): self
196
    {
197 90
        $new = clone $this;
198 90
        $new->publisher = $publisher;
199 90
        return $new;
200
    }
201
202
    /**
203
     * Exports registered asset bundles.
204
     *
205
     * When using the allowed asset bundles, the export result will always be the same,
206
     * since the asset bundles are registered before the export. If do not use the allowed asset bundles mode,
207
     * must register {@see register()} all the required asset bundles before exporting.
208
     *
209
     * @param AssetExporterInterface $exporter The exporter instance.
210
     *
211
     * @throws InvalidConfigException If an error occurs during registration when using allowed asset bundles.
212
     * @throws RuntimeException If no asset bundles were registered or an error occurred during the export.
213
     */
214 8
    public function export(AssetExporterInterface $exporter): void
215
    {
216 8
        if (!empty($this->allowedBundleNames)) {
217 3
            $this->registerAllAllowed();
218
        }
219
220 8
        if (empty($this->registeredBundles)) {
221 1
            throw new RuntimeException('Not a single asset bundle was registered.');
222
        }
223
224 7
        $exporter->export($this->registeredBundles);
225 7
    }
226
227
    /**
228
     * Registers asset bundles by names.
229
     *
230
     * @param string[] $names
231
     * @param int|null $position
232
     *
233
     * @throws InvalidConfigException
234
     * @throws RuntimeException
235
     */
236 90
    public function register(array $names, ?int $position = null): void
237
    {
238 90
        if (!empty($this->allowedBundleNames)) {
239 3
            foreach ($names as $name) {
240 3
                $this->checkAllowedBundleName($name);
241
            }
242
        }
243
244 90
        foreach ($names as $name) {
245 40
            $this->registerAssetBundle($name, $position);
246 36
            $this->registerFiles($name);
247
        }
248 90
    }
249
250
    /**
251
     * Registers all allowed asset bundles.
252
     *
253
     * @throws InvalidConfigException
254
     * @throws RuntimeException
255
     */
256 5
    public function registerAllAllowed(): void
257
    {
258 5
        if (empty($this->allowedBundleNames)) {
259 1
            throw new RuntimeException('The allowed names of the asset bundles were not set.');
260
        }
261
262 4
        foreach ($this->allowedBundleNames as $name) {
263 4
            $this->registerAssetBundle($name);
264 4
            $this->registerFiles($name);
265
        }
266 4
    }
267
268
    /**
269
     * Returns whether the asset bundle is registered.
270
     *
271
     * @param string $name The class name of the asset bundle (without the leading backslash).
272
     *
273
     * @return bool Whether the asset bundle is registered.
274
     */
275 4
    public function isRegisteredBundle(string $name): bool
276
    {
277 4
        return isset($this->registeredBundles[$name]);
278
    }
279
280
    /**
281
     * Registers a CSS file.
282
     *
283
     * @param string $url The CSS file to be registered.
284
     * @param array $options The HTML attributes for the link tag.
285
     * @param string|null $key The key that identifies the CSS file.
286
     */
287 35
    private function registerCssFile(string $url, array $options = [], string $key = null): void
288
    {
289 35
        $key = $key ?: $url;
290
291 35
        $this->cssFiles[$key]['url'] = $url;
292 35
        $this->cssFiles[$key]['attributes'] = $options;
293 35
    }
294
295
    /**
296
     * Registers a JS file.
297
     *
298
     * @param string $url The JS file to be registered.
299
     * @param array $options The HTML attributes for the script tag. The following options are specially handled and
300
     * are not treated as HTML attributes:
301
     *
302
     * - `position`: specifies where the JS script tag should be inserted in a page. The possible values are:
303
     *     * {@see \Yiisoft\View\WebView::POSITION_HEAD} In the head section.
304
     *     * {@see \Yiisoft\View\WebView::POSITION_BEGIN} At the beginning of the body section.
305
     *     * {@see \Yiisoft\View\WebView::POSITION_END} At the end of the body section. This is the default value.
306
     * @param string|null $key The key that identifies the JS file.
307
     */
308 45
    private function registerJsFile(string $url, array $options = [], string $key = null): void
309
    {
310 45
        $key = $key ?: $url;
311
312 45
        if (!array_key_exists('position', $options)) {
313 39
            $options = array_merge(['position' => 3], $options);
314
        }
315
316 45
        $this->jsFiles[$key]['url'] = $url;
317 45
        $this->jsFiles[$key]['attributes'] = $options;
318 45
    }
319
320
    /**
321
     * Converter SASS, SCSS, Stylus and other formats to CSS.
322
     *
323
     * @param AssetBundle $bundle
324
     */
325 15
    private function convertCss(AssetBundle $bundle): void
326
    {
327 15
        foreach ($bundle->css as $i => $css) {
328 14
            if (is_array($css)) {
329 1
                $file = array_shift($css);
330 1
                if (AssetUtil::isRelative($file)) {
331 1
                    $css = array_merge($bundle->cssOptions, $css);
332 1
                    $baseFile = $this->aliases->get("{$bundle->basePath}/{$file}");
333 1
                    if (is_file($baseFile)) {
334
                        /**
335
                         * @psalm-suppress PossiblyNullArgument
336
                         * @psalm-suppress PossiblyNullReference
337
                         */
338 1
                        array_unshift($css, $this->converter->convert(
0 ignored issues
show
Bug introduced by
The method convert() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

338
                        array_unshift($css, $this->converter->/** @scrutinizer ignore-call */ convert(

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
339 1
                            $file,
340 1
                            $bundle->basePath,
0 ignored issues
show
Bug introduced by
It seems like $bundle->basePath can also be of type null; however, parameter $basePath of Yiisoft\Assets\AssetConverterInterface::convert() 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

340
                            /** @scrutinizer ignore-type */ $bundle->basePath,
Loading history...
341 1
                            $bundle->converterOptions,
342
                        ));
343
344 1
                        $bundle->css[$i] = $css;
345
                    }
346
                }
347 14
            } elseif (AssetUtil::isRelative($css)) {
348 14
                $baseCss = $this->aliases->get("{$bundle->basePath}/{$css}");
349 14
                if (is_file("$baseCss")) {
350
                    /**
351
                     * @psalm-suppress PossiblyNullArgument
352
                     * @psalm-suppress PossiblyNullReference
353
                     */
354 13
                    $bundle->css[$i] = $this->converter->convert(
355 13
                        $css,
356 13
                        $bundle->basePath,
357 13
                        $bundle->converterOptions
358
                    );
359
                }
360
            }
361
        }
362 15
    }
363
364
    /**
365
     * Convert files from TypeScript and other formats into JavaScript.
366
     *
367
     * @param AssetBundle $bundle
368
     */
369 15
    private function convertJs(AssetBundle $bundle): void
370
    {
371 15
        foreach ($bundle->js as $i => $js) {
372 15
            if (is_array($js)) {
373 2
                $file = array_shift($js);
374 2
                if (AssetUtil::isRelative($file)) {
375 2
                    $js = array_merge($bundle->jsOptions, $js);
376 2
                    $baseFile = $this->aliases->get("{$bundle->basePath}/{$file}");
377 2
                    if (is_file($baseFile)) {
378
                        /**
379
                         * @psalm-suppress PossiblyNullArgument
380
                         * @psalm-suppress PossiblyNullReference
381
                         */
382 2
                        array_unshift($js, $this->converter->convert(
383 2
                            $file,
384 2
                            $bundle->basePath,
0 ignored issues
show
Bug introduced by
It seems like $bundle->basePath can also be of type null; however, parameter $basePath of Yiisoft\Assets\AssetConverterInterface::convert() 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

384
                            /** @scrutinizer ignore-type */ $bundle->basePath,
Loading history...
385 2
                            $bundle->converterOptions
386
                        ));
387
388 2
                        $bundle->js[$i] = $js;
389
                    }
390
                }
391 15
            } elseif (AssetUtil::isRelative($js)) {
392 15
                $baseJs = $this->aliases->get("{$bundle->basePath}/{$js}");
393 15
                if (is_file($baseJs)) {
394
                    /**
395
                     * @psalm-suppress PossiblyNullArgument
396
                     * @psalm-suppress PossiblyNullReference
397
                     */
398 14
                    $bundle->js[$i] = $this->converter->convert($js, $bundle->basePath);
399
                }
400
            }
401
        }
402 15
    }
403
404
    /**
405
     * Registers the named asset bundle.
406
     *
407
     * All dependent asset bundles will be registered.
408
     *
409
     * @param string $name The class name of the asset bundle (without the leading backslash).
410
     * @param int|null $position If set, this forces a minimum position for javascript files.
411
     * This will adjust depending assets javascript file position or fail if requirement can not be met.
412
     * If this is null, asset bundles position settings will not be changed.
413
     *
414
     * {@see registerJsFile()} For more details on javascript position.
415
     *
416
     * @throws InvalidConfigException If the asset or the asset file paths to be published does not exist.
417
     * @throws RuntimeException If the asset bundle does not exist or a circular dependency is detected.
418
     */
419 44
    private function registerAssetBundle(string $name, int $position = null): void
420
    {
421 44
        if (!isset($this->registeredBundles[$name])) {
422 44
            $bundle = $this->publishBundle($this->loadBundle($name));
423
424 43
            $this->registeredBundles[$name] = false;
425
426 43
            $pos = $bundle->jsOptions['position'] ?? null;
427
428 43
            foreach ($bundle->depends as $dep) {
429 31
                $this->registerAssetBundle($dep, $pos);
430
            }
431
432 42
            unset($this->registeredBundles[$name]);
433 42
            $this->registeredBundles[$name] = $bundle;
434 12
        } elseif ($this->registeredBundles[$name] === false) {
435 1
            throw new RuntimeException("A circular dependency is detected for bundle \"{$name}\".");
436
        } else {
437 11
            $bundle = $this->registeredBundles[$name];
438
        }
439
440 42
        if ($position !== null) {
441 11
            $pos = $bundle->jsOptions['position'] ?? null;
442
443 11
            if ($pos === null) {
444 10
                $bundle->jsOptions['position'] = $pos = $position;
445 5
            } elseif ($pos > $position) {
446 4
                throw new RuntimeException(
447 4
                    "An asset bundle that depends on \"{$name}\" has a higher JavaScript file " .
448 4
                    "position configured than \"{$name}\"."
449
                );
450
            }
451
452
            // update position for all dependencies
453 11
            foreach ($bundle->depends as $dep) {
454 7
                $this->registerAssetBundle($dep, $pos);
455
            }
456
        }
457 42
    }
458
459
    /**
460
     * Register assets from a named bundle and its dependencies.
461
     *
462
     * @param string $bundleName The asset bundle name.
463
     *
464
     * @throws InvalidConfigException If asset files are not found.
465
     */
466 40
    private function registerFiles(string $bundleName): void
467
    {
468 40
        if (!isset($this->registeredBundles[$bundleName])) {
469
            return;
470
        }
471
472 40
        $bundle = $this->registeredBundles[$bundleName];
473
474 40
        foreach ($bundle->depends as $dep) {
475 28
            $this->registerFiles($dep);
476
        }
477
478 40
        $this->registerAssetFiles($bundle);
479 37
    }
480
481
    /**
482
     * Registers asset files from a bundle considering dependencies.
483
     *
484
     * @param AssetBundle $bundle
485
     *
486
     * @throws InvalidConfigException If asset files are not found.
487
     */
488 40
    private function registerAssetFiles(AssetBundle $bundle): void
489
    {
490 40
        if (isset($bundle->basePath, $bundle->baseUrl) && null !== $this->converter) {
491 15
            $this->convertCss($bundle);
492 15
            $this->convertJs($bundle);
493
        }
494
495 40
        foreach ($bundle->js as $js) {
496 38
            if (is_array($js)) {
497 3
                $file = array_shift($js);
498 3
                $options = array_merge($bundle->jsOptions, $js);
499 3
                $this->registerJsFile($this->loader->getAssetUrl($bundle, $file), $options);
500 37
            } elseif ($js !== null) {
501 37
                $this->registerJsFile($this->loader->getAssetUrl($bundle, $js), $bundle->jsOptions);
502
            }
503
        }
504
505 37
        $this->jsStrings = array_merge($this->jsStrings, $bundle->jsStrings);
506 37
        $this->jsVars = array_merge($this->jsVars, $bundle->jsVars);
507
508 37
        foreach ($bundle->css as $css) {
509 25
            if (is_array($css)) {
510 1
                $file = array_shift($css);
511 1
                $options = array_merge($bundle->cssOptions, $css);
512 1
                $this->registerCssFile($this->loader->getAssetUrl($bundle, $file), $options);
513 25
            } elseif ($css !== null) {
514 25
                $this->registerCssFile($this->loader->getAssetUrl($bundle, $css), $bundle->cssOptions);
515
            }
516
        }
517 37
    }
518
519
    /**
520
     * Loads an asset bundle class by name.
521
     *
522
     * @param string $name The asset bundle name.
523
     *
524
     * @throws InvalidConfigException For invalid asset bundle configuration.
525
     *
526
     * @return AssetBundle The asset bundle instance.
527
     */
528 47
    private function loadBundle(string $name): AssetBundle
529
    {
530 47
        if (isset($this->loadedBundles[$name])) {
531 8
            return $this->loadedBundles[$name];
532
        }
533
534 47
        if (!isset($this->customizedBundles[$name])) {
535 42
            return $this->loadedBundles[$name] = $this->loader->loadBundle($name);
536
        }
537
538 20
        if ($this->customizedBundles[$name] instanceof AssetBundle) {
539 1
            return $this->loadedBundles[$name] = $this->customizedBundles[$name];
540
        }
541
542 19
        if (is_array($this->customizedBundles[$name])) {
543 17
            return $this->loadedBundles[$name] = $this->loader->loadBundle($name, $this->customizedBundles[$name]);
544
        }
545
546 2
        if ($this->customizedBundles[$name] === false) {
547 1
            return $this->dummyBundles[$name] ??= $this->loader->loadBundle($name, (array) (new AssetBundle()));
548
        }
549
550 1
        throw new InvalidConfigException("Invalid configuration of the \"{$name}\" asset bundle.");
551
    }
552
553
    /**
554
     * Publishes a asset bundle.
555
     *
556
     * @param AssetBundle $bundle The asset bundle to publish.
557
     *
558
     * @throws InvalidConfigException If the asset or the asset file paths to be published does not exist.
559
     *
560
     * @return AssetBundle The published asset bundle.
561
     */
562 46
    private function publishBundle(AssetBundle $bundle): AssetBundle
563
    {
564 46
        if (!$bundle->cdn && $this->publisher !== null && !empty($bundle->sourcePath)) {
565 13
            [$bundle->basePath, $bundle->baseUrl] = $this->publisher->publish($bundle);
566
        }
567
568 46
        return $bundle;
569
    }
570
571
    /**
572
     * Checks whether asset bundle are allowed by name {@see $allowedBundleNames}.
573
     *
574
     * @param string $name The asset bundle name to check.
575
     *
576
     * @throws InvalidConfigException For invalid asset bundle configuration.
577
     * @throws RuntimeException If The asset bundle name is not allowed.
578
     */
579 4
    public function checkAllowedBundleName(string $name): void
580
    {
581 4
        if (isset($this->loadedBundles[$name]) || in_array($name, $this->allowedBundleNames, true)) {
582 4
            return;
583
        }
584
585 3
        foreach ($this->allowedBundleNames as $bundleName) {
586 3
            if ($this->isAllowedBundleDependencies($name, $this->loadBundle($bundleName))) {
587 1
                return;
588
            }
589
        }
590
591 3
        throw new RuntimeException("The \"{$name}\" asset bundle is not allowed.");
592
    }
593
594
    /**
595
     * Recursively checks whether the asset bundle name is allowed in dependencies.
596
     *
597
     * @param string $name The asset bundle name to check.
598
     * @param AssetBundle $bundle The asset bundle to check.
599
     *
600
     * @throws InvalidConfigException For invalid asset bundle configuration.
601
     *
602
     * @return bool Whether the asset bundle name is allowed in dependencies.
603
     */
604 3
    private function isAllowedBundleDependencies(string $name, AssetBundle $bundle): bool
605
    {
606 3
        foreach ($bundle->depends as $depend) {
607 2
            if ($name === $depend || $this->isAllowedBundleDependencies($name, $this->loadBundle($depend))) {
608 1
                return true;
609
            }
610
        }
611
612 3
        return false;
613
    }
614
}
615