Passed
Push — 6.5.0.0 ( 9dcbd7...7d5d91 )
by Christian
14:29 queued 12s
created

ThemeCompiler::compileStyles()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 54
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 27
nc 8
nop 6
dl 0
loc 54
rs 8.8657
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php declare(strict_types=1);
2
3
namespace Shopware\Storefront\Theme;
4
5
use League\Flysystem\FilesystemOperator;
6
use Padaliyajay\PHPAutoprefixer\Autoprefixer;
7
use ScssPhp\ScssPhp\OutputStyle;
8
use Shopware\Core\Framework\Adapter\Cache\CacheInvalidator;
9
use Shopware\Core\Framework\Adapter\Filesystem\Plugin\CopyBatch;
10
use Shopware\Core\Framework\Context;
11
use Shopware\Core\Framework\Feature;
12
use Shopware\Core\Framework\Uuid\Uuid;
13
use Shopware\Storefront\Event\ThemeCompilerConcatenatedScriptsEvent;
14
use Shopware\Storefront\Event\ThemeCompilerConcatenatedStylesEvent;
15
use Shopware\Storefront\Theme\Event\ThemeCompilerEnrichScssVariablesEvent;
16
use Shopware\Storefront\Theme\Exception\InvalidThemeException;
17
use Shopware\Storefront\Theme\Exception\ThemeCompileException;
18
use Shopware\Storefront\Theme\Message\DeleteThemeFilesMessage;
19
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\FileCollection;
20
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfiguration;
21
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfigurationCollection;
22
use Symfony\Component\Asset\Package;
23
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
24
use Symfony\Component\Messenger\Envelope;
25
use Symfony\Component\Messenger\MessageBusInterface;
26
use Symfony\Component\Messenger\Stamp\DelayStamp;
27
28
#[\Shopware\Core\Framework\Log\Package('storefront')]
29
class ThemeCompiler implements ThemeCompilerInterface
30
{
31
    /**
32
     * @internal
33
     *
34
     * @param Package[] $packages
35
     */
36
    public function __construct(
37
        private readonly FilesystemOperator $filesystem,
38
        private readonly FilesystemOperator $tempFilesystem,
39
        private readonly ThemeFileResolver $themeFileResolver,
40
        private readonly bool $debug,
41
        private readonly EventDispatcherInterface $eventDispatcher,
42
        private readonly ThemeFileImporterInterface $themeFileImporter,
43
        private readonly iterable $packages,
44
        private readonly CacheInvalidator $logger,
45
        private readonly AbstractThemePathBuilder $themePathBuilder,
46
        private readonly string $projectDir,
47
        private readonly AbstractScssCompiler $scssCompiler,
48
        private readonly MessageBusInterface $messageBus,
49
        private readonly int $themeFileDeleteDelay,
50
        private readonly bool $autoPrefix = false
51
    ) {
52
    }
53
54
    public function compileTheme(
55
        string $salesChannelId,
56
        string $themeId,
57
        StorefrontPluginConfiguration $themeConfig,
58
        StorefrontPluginConfigurationCollection $configurationCollection,
59
        bool $withAssets,
60
        Context $context
61
    ): void {
62
        $resolvedFiles = $this->themeFileResolver->resolveFiles($themeConfig, $configurationCollection, false);
63
64
        $styleFiles = $resolvedFiles[ThemeFileResolver::STYLE_FILES];
65
66
        $concatenatedStyles = $this->concatenateStyles(
67
            $styleFiles,
68
            $themeConfig,
69
            $salesChannelId
70
        );
71
72
        $compiled = $this->compileStyles(
73
            $concatenatedStyles,
74
            $themeConfig,
75
            $styleFiles->getResolveMappings(),
76
            $salesChannelId,
77
            $themeId,
78
            $context
79
        );
80
81
        $concatenatedScripts = $this->getConcatenatedScripts($resolvedFiles[ThemeFileResolver::SCRIPT_FILES], $themeConfig, $salesChannelId);
82
83
        $newThemeHash = Uuid::randomHex();
84
        $themePrefix = $this->themePathBuilder->generateNewPath($salesChannelId, $themeId, $newThemeHash);
0 ignored issues
show
Deprecated Code introduced by
The function Shopware\Storefront\Them...lder::generateNewPath() has been deprecated: tag:v6.6.0 - Method will be abstract in v6.6.0, so implement the method in your implementations ( Ignorable by Annotation )

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

84
        $themePrefix = /** @scrutinizer ignore-deprecated */ $this->themePathBuilder->generateNewPath($salesChannelId, $themeId, $newThemeHash);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
85
        $oldThemePrefix = $this->themePathBuilder->assemblePath($salesChannelId, $themeId);
86
87
        try {
88
            $this->writeCompiledFiles($themePrefix, $themeId, $compiled, $concatenatedScripts, $withAssets, $themeConfig, $configurationCollection);
89
        } catch (\Throwable $e) {
90
            // delete folder in case of error and rethrow exception
91
            if ($themePrefix !== $oldThemePrefix) {
92
                $this->filesystem->deleteDirectory($themePrefix);
93
            }
94
95
            throw $e;
96
        }
97
98
        $this->themePathBuilder->saveSeed($salesChannelId, $themeId, $newThemeHash);
0 ignored issues
show
Deprecated Code introduced by
The function Shopware\Storefront\Them...PathBuilder::saveSeed() has been deprecated: tag:v6.6.0 - Method will be abstract in v6.6.0, so implement the method in your implementations ( Ignorable by Annotation )

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

98
        /** @scrutinizer ignore-deprecated */ $this->themePathBuilder->saveSeed($salesChannelId, $themeId, $newThemeHash);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
99
100
        // only delete the old directory if the `themePathBuilder` actually returned a new path and supports seeding
101
        if ($themePrefix !== $oldThemePrefix) {
102
            $stamps = [];
103
104
            if ($this->themeFileDeleteDelay > 0) {
105
                // also delete with a delay, so that the old theme is still available for a while in case some CDN delivers stale content
106
                // delay is configured in seconds, symfony expects milliseconds
107
                $stamps[] = new DelayStamp($this->themeFileDeleteDelay * 1000);
108
            }
109
            $this->messageBus->dispatch(
110
                new Envelope(
111
                    new DeleteThemeFilesMessage($oldThemePrefix, $salesChannelId, $themeId),
112
                    $stamps
113
                )
114
            );
115
        }
116
117
        // Reset cache buster state for improving performance in getMetadata
118
        $this->logger->invalidate(['theme-metaData'], true);
119
    }
120
121
    /**
122
     * @param array<string, string> $resolveMappings
123
     */
124
    public function getResolveImportPathsCallback(array $resolveMappings): \Closure
125
    {
126
        return function ($originalPath) use ($resolveMappings) {
127
            foreach ($resolveMappings as $resolve => $resolvePath) {
128
                $resolve = '~' . $resolve;
129
                if (mb_strpos($originalPath, $resolve) === 0) {
130
                    $dirname = $resolvePath . \dirname(mb_substr($originalPath, mb_strlen($resolve)));
131
132
                    $filename = basename($originalPath);
133
                    $extension = $this->getImportFileExtension(pathinfo($filename, \PATHINFO_EXTENSION));
0 ignored issues
show
Bug introduced by
It seems like pathinfo($filename, PATHINFO_EXTENSION) can also be of type array; however, parameter $extension of Shopware\Storefront\Them...etImportFileExtension() 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

133
                    $extension = $this->getImportFileExtension(/** @scrutinizer ignore-type */ pathinfo($filename, \PATHINFO_EXTENSION));
Loading history...
134
                    $path = $dirname . \DIRECTORY_SEPARATOR . $filename . $extension;
135
                    if (file_exists($path)) {
136
                        return $path;
137
                    }
138
139
                    $path = $dirname . \DIRECTORY_SEPARATOR . '_' . $filename . $extension;
140
                    if (file_exists($path)) {
141
                        return $path;
142
                    }
143
                }
144
            }
145
146
            return null;
147
        };
148
    }
149
150
    private function copyAssets(
151
        StorefrontPluginConfiguration $configuration,
152
        StorefrontPluginConfigurationCollection $configurationCollection,
153
        string $outputPath
154
    ): void {
155
        if (!$configuration->getAssetPaths()) {
156
            return;
157
        }
158
159
        foreach ($configuration->getAssetPaths() as $asset) {
160
            if (mb_strpos((string) $asset, '@') === 0) {
161
                $name = mb_substr((string) $asset, 1);
162
                $config = $configurationCollection->getByTechnicalName($name);
163
                if (!$config) {
164
                    throw new InvalidThemeException($name);
165
                }
166
167
                $this->copyAssets($config, $configurationCollection, $outputPath);
168
169
                continue;
170
            }
171
172
            if ($asset[0] !== '/' && file_exists($this->projectDir . '/' . $asset)) {
173
                $asset = $this->projectDir . '/' . $asset;
174
            }
175
176
            $assets = $this->themeFileImporter->getCopyBatchInputsForAssets($asset, $outputPath, $configuration);
177
178
            CopyBatch::copy($this->filesystem, ...$assets);
179
        }
180
    }
181
182
    /**
183
     * @param array<string, string> $resolveMappings
184
     */
185
    private function compileStyles(
186
        string $concatenatedStyles,
187
        StorefrontPluginConfiguration $configuration,
188
        array $resolveMappings,
189
        string $salesChannelId,
190
        string $themeId,
191
        Context $context
192
    ): string {
193
        $variables = $this->dumpVariables($configuration->getThemeConfig() ?? [], $themeId, $salesChannelId, $context);
194
        $features = $this->getFeatureConfigScssMap();
195
196
        $resolveImportPath = $this->getResolveImportPathsCallback($resolveMappings);
197
198
        $importPaths = [];
199
200
        $cwd = \getcwd();
201
        if ($cwd !== false) {
202
            $importPaths[] = $cwd;
203
        }
204
205
        $importPaths[] = $resolveImportPath;
206
207
        $compilerConfig = new CompilerConfiguration(
208
            [
209
                'importPaths' => $importPaths,
210
                'outputStyle' => $this->debug ? OutputStyle::EXPANDED : OutputStyle::COMPRESSED,
211
            ]
212
        );
213
214
        try {
215
            $cssOutput = $this->scssCompiler->compileString(
216
                $compilerConfig,
217
                $features . $variables . $concatenatedStyles
218
            );
219
        } catch (\Throwable $exception) {
220
            throw new ThemeCompileException(
221
                $configuration->getTechnicalName(),
222
                $exception->getMessage()
223
            );
224
        }
225
226
        if ($this->autoPrefix === true) {
227
            $autoPreFixer = new Autoprefixer($cssOutput);
228
            /** @var string|false $cssOutput */
229
            $cssOutput = $autoPreFixer->compile($this->debug);
230
            if ($cssOutput === false) {
231
                throw new ThemeCompileException(
232
                    $configuration->getTechnicalName(),
233
                    'CSS parser not initialized'
234
                );
235
            }
236
        }
237
238
        return $cssOutput;
239
    }
240
241
    private function getImportFileExtension(string $extension): string
242
    {
243
        // If the import has no extension, it must be a SCSS module.
244
        if ($extension === '') {
245
            return '.scss';
246
        }
247
248
        // If the import has a .min extension, we assume it must be a compiled CSS file.
249
        if ($extension === 'min') {
250
            return '.css';
251
        }
252
253
        // If it has any other extension, we don't assume a specific extension.
254
        return '';
255
    }
256
257
    /**
258
     * Converts the feature config array to a SCSS map syntax.
259
     * This allows reading of the feature flag config inside SCSS via `map.get` function.
260
     *
261
     * Output example:
262
     * $sw-features: ("FEATURE_NEXT_1234": false, "FEATURE_NEXT_1235": true);
263
     *
264
     * @see https://sass-lang.com/documentation/values/maps
265
     */
266
    private function getFeatureConfigScssMap(): string
267
    {
268
        $allFeatures = Feature::getAll();
269
270
        $featuresScss = implode(',', array_map(fn ($value, $key) => sprintf('"%s": %s', $key, json_encode($value, \JSON_THROW_ON_ERROR)), $allFeatures, array_keys($allFeatures)));
271
272
        return sprintf('$sw-features: (%s);', $featuresScss);
273
    }
274
275
    /**
276
     * @param array<string, string|int> $variables
277
     *
278
     * @return array<string>
279
     */
280
    private function formatVariables(array $variables): array
281
    {
282
        return array_map(fn ($value, $key) => sprintf('$%s: %s;', $key, (!empty($value) ? $value : 0)), $variables, array_keys($variables));
283
    }
284
285
    /**
286
     * @param array{fields?: array{value: null|string|array<mixed>, scss?: bool, type: string}[]} $config
287
     */
288
    private function dumpVariables(array $config, string $themeId, string $salesChannelId, Context $context): string
289
    {
290
        $variables = [
291
            'theme-id' => $themeId,
292
        ];
293
294
        foreach ($config['fields'] ?? [] as $key => $data) {
295
            if (!\is_array($data) || !$this->isDumpable($data)) {
296
                continue;
297
            }
298
299
            if (\in_array($data['type'], ['media', 'textarea'], true) && \is_string($data['value'])) {
300
                $variables[$key] = '\'' . $data['value'] . '\'';
301
            } elseif ($data['type'] === 'switch' || $data['type'] === 'checkbox') {
302
                $variables[$key] = (int) ($data['value']);
303
            } elseif (!\is_array($data['value'])) {
304
                $variables[$key] = (string) $data['value'];
305
            }
306
        }
307
308
        foreach ($this->packages as $key => $package) {
309
            $variables[sprintf('sw-asset-%s-url', $key)] = sprintf('\'%s\'', $package->getUrl(''));
310
        }
311
312
        $themeVariablesEvent = new ThemeCompilerEnrichScssVariablesEvent(
313
            $variables,
314
            $salesChannelId,
315
            $context
316
        );
317
318
        $this->eventDispatcher->dispatch($themeVariablesEvent);
319
320
        $dump = str_replace(
321
            ['#class#', '#variables#'],
322
            [self::class, implode(\PHP_EOL, $this->formatVariables($themeVariablesEvent->getVariables()))],
323
            $this->getVariableDumpTemplate()
324
        );
325
326
        $this->tempFilesystem->write('theme-variables.scss', $dump);
327
328
        return $dump;
329
    }
330
331
    /**
332
     * @param array{value: string|array<mixed>|null, scss?: bool, type: string} $data
333
     */
334
    private function isDumpable(array $data): bool
335
    {
336
        if (!isset($data['value'])) {
337
            return false;
338
        }
339
340
        // Do not include fields which have the scss option set to false
341
        if (\array_key_exists('scss', $data) && $data['scss'] === false) {
342
            return false;
343
        }
344
345
        // Do not include fields which haa an array as value
346
        if (\is_array($data['value'])) {
347
            return false;
348
        }
349
350
        // value must not be an empty string since because an empty value can not be compiled
351
        if ($data['value'] === '') {
352
            return false;
353
        }
354
355
        // if no type is set just use the value and continue
356
        if (!isset($data['type'])) {
357
            return false;
358
        }
359
360
        return true;
361
    }
362
363
    private function getVariableDumpTemplate(): string
364
    {
365
        return <<<PHP_EOL
366
// ATTENTION! This file is auto generated by the #class# and should not be edited.
367
368
#variables#
369
370
PHP_EOL;
371
    }
372
373
    private function concatenateStyles(
374
        FileCollection $styleFiles,
375
        StorefrontPluginConfiguration $themeConfig,
376
        string $salesChannelId
377
    ): string {
378
        $concatenatedStyles = '';
379
        foreach ($styleFiles as $file) {
380
            $concatenatedStyles .= $this->themeFileImporter->getConcatenableStylePath($file, $themeConfig);
0 ignored issues
show
Bug introduced by
$file of type array is incompatible with the type Shopware\Storefront\Them...luginConfiguration\File expected by parameter $file of Shopware\Storefront\Them...ConcatenableStylePath(). ( Ignorable by Annotation )

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

380
            $concatenatedStyles .= $this->themeFileImporter->getConcatenableStylePath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
381
        }
382
        $concatenatedStylesEvent = new ThemeCompilerConcatenatedStylesEvent($concatenatedStyles, $salesChannelId);
383
        $this->eventDispatcher->dispatch($concatenatedStylesEvent);
384
385
        return $concatenatedStylesEvent->getConcatenatedStyles();
386
    }
387
388
    private function getConcatenatedScripts(
389
        FileCollection $scriptFiles,
390
        StorefrontPluginConfiguration $themeConfig,
391
        string $salesChannelId
392
    ): string {
393
        $concatenatedScripts = '';
394
        foreach ($scriptFiles as $file) {
395
            $concatenatedScripts .= $this->themeFileImporter->getConcatenableScriptPath($file, $themeConfig);
0 ignored issues
show
Bug introduced by
$file of type array is incompatible with the type Shopware\Storefront\Them...luginConfiguration\File expected by parameter $file of Shopware\Storefront\Them...oncatenableScriptPath(). ( Ignorable by Annotation )

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

395
            $concatenatedScripts .= $this->themeFileImporter->getConcatenableScriptPath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
396
        }
397
398
        $concatenatedScriptsEvent = new ThemeCompilerConcatenatedScriptsEvent($concatenatedScripts, $salesChannelId);
399
        $this->eventDispatcher->dispatch($concatenatedScriptsEvent);
400
401
        return $concatenatedScriptsEvent->getConcatenatedScripts();
402
    }
403
404
    private function writeCompiledFiles(
405
        string $themePrefix,
406
        string $themeId,
407
        string $compiled,
408
        string $concatenatedScripts,
409
        bool $withAssets,
410
        StorefrontPluginConfiguration $themeConfig,
411
        StorefrontPluginConfigurationCollection $configurationCollection
412
    ): void {
413
        $path = 'theme' . \DIRECTORY_SEPARATOR . $themePrefix;
414
415
        if ($this->filesystem->has($path)) {
416
            $this->filesystem->deleteDirectory($path);
417
        }
418
419
        $cssFilePath = $path . \DIRECTORY_SEPARATOR . 'css' . \DIRECTORY_SEPARATOR . 'all.css';
420
        $this->filesystem->write($cssFilePath, $compiled);
421
422
        $scriptFilepath = $path . \DIRECTORY_SEPARATOR . 'js' . \DIRECTORY_SEPARATOR . 'all.js';
423
        $this->filesystem->write($scriptFilepath, $concatenatedScripts);
424
425
        // assets
426
        if ($withAssets) {
427
            $assetPath = 'theme' . \DIRECTORY_SEPARATOR . $themeId;
428
            if ($this->filesystem->has($assetPath)) {
429
                $this->filesystem->deleteDirectory($assetPath);
430
            }
431
432
            $this->copyAssets($themeConfig, $configurationCollection, $assetPath);
433
        }
434
    }
435
}
436