Passed
Push — trunk ( 1de2f1...ebcc82 )
by Christian
12:45 queued 12s
created

ThemeCompiler::compileStyles()   B

Complexity

Conditions 6
Paths 17

Size

Total Lines 55
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 28
nc 17
nop 6
dl 0
loc 55
rs 8.8497
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
        try {
63
            $resolvedFiles = $this->themeFileResolver->resolveFiles($themeConfig, $configurationCollection, false);
64
65
            $styleFiles = $resolvedFiles[ThemeFileResolver::STYLE_FILES];
66
        } catch (\Throwable $e) {
67
            throw new ThemeCompileException(
68
                $themeConfig->getName() ?? '',
69
                'Files could not be resolved with error: ' . $e->getMessage(),
70
                $e
71
            );
72
        }
73
74
        try {
75
            $concatenatedStyles = $this->concatenateStyles(
76
                $styleFiles,
77
                $themeConfig,
78
                $salesChannelId
79
            );
80
        } catch (\Throwable $e) {
81
            throw new ThemeCompileException(
82
                $themeConfig->getName() ?? '',
83
                'Error while trying to concatenate Styles: ' . $e->getMessage(),
84
                $e
85
            );
86
        }
87
88
        $compiled = $this->compileStyles(
89
            $concatenatedStyles,
90
            $themeConfig,
91
            $styleFiles->getResolveMappings(),
92
            $salesChannelId,
93
            $themeId,
94
            $context
95
        );
96
97
        try {
98
            $concatenatedScripts = $this->getConcatenatedScripts($resolvedFiles[ThemeFileResolver::SCRIPT_FILES], $themeConfig, $salesChannelId);
99
        } catch (\Throwable $e) {
100
            throw new ThemeCompileException(
101
                $themeConfig->getName() ?? '',
102
                'Error while trying to concatenate Scripts: ' . $e->getMessage(),
103
                $e
104
            );
105
        }
106
107
        $newThemeHash = Uuid::randomHex();
108
        $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

108
        $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...
109
        $oldThemePrefix = $this->themePathBuilder->assemblePath($salesChannelId, $themeId);
110
111
        try {
112
            $this->writeCompiledFiles($themePrefix, $themeId, $compiled, $concatenatedScripts, $withAssets, $themeConfig, $configurationCollection);
113
        } catch (\Throwable $e) {
114
            // delete folder in case of error and rethrow exception
115
            if ($themePrefix !== $oldThemePrefix) {
116
                $this->filesystem->deleteDirectory($themePrefix);
117
            }
118
119
            throw new ThemeCompileException(
120
                $themeConfig->getName() ?? '',
121
                'Error while trying to write compiled files: ' . $e->getMessage(),
122
                $e
123
            );
124
        }
125
126
        $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

126
        /** @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...
127
128
        // only delete the old directory if the `themePathBuilder` actually returned a new path and supports seeding
129
        if ($themePrefix !== $oldThemePrefix) {
130
            $stamps = [];
131
132
            if ($this->themeFileDeleteDelay > 0) {
133
                // also delete with a delay, so that the old theme is still available for a while in case some CDN delivers stale content
134
                // delay is configured in seconds, symfony expects milliseconds
135
                $stamps[] = new DelayStamp($this->themeFileDeleteDelay * 1000);
136
            }
137
            $this->messageBus->dispatch(
138
                new Envelope(
139
                    new DeleteThemeFilesMessage($oldThemePrefix, $salesChannelId, $themeId),
140
                    $stamps
141
                )
142
            );
143
        }
144
145
        // Reset cache buster state for improving performance in getMetadata
146
        $this->logger->invalidate(['theme-metaData'], true);
147
    }
148
149
    /**
150
     * @param array<string, string> $resolveMappings
151
     */
152
    public function getResolveImportPathsCallback(array $resolveMappings): \Closure
153
    {
154
        return function ($originalPath) use ($resolveMappings) {
155
            foreach ($resolveMappings as $resolve => $resolvePath) {
156
                $resolve = '~' . $resolve;
157
                if (mb_strpos($originalPath, $resolve) === 0) {
158
                    $dirname = $resolvePath . \dirname(mb_substr($originalPath, mb_strlen($resolve)));
159
160
                    $filename = basename($originalPath);
161
                    $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

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

409
            $concatenatedStyles .= $this->themeFileImporter->getConcatenableStylePath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
410
        }
411
        $concatenatedStylesEvent = new ThemeCompilerConcatenatedStylesEvent($concatenatedStyles, $salesChannelId);
412
        $this->eventDispatcher->dispatch($concatenatedStylesEvent);
413
414
        return $concatenatedStylesEvent->getConcatenatedStyles();
415
    }
416
417
    private function getConcatenatedScripts(
418
        FileCollection $scriptFiles,
419
        StorefrontPluginConfiguration $themeConfig,
420
        string $salesChannelId
421
    ): string {
422
        $concatenatedScripts = '';
423
        foreach ($scriptFiles as $file) {
424
            $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

424
            $concatenatedScripts .= $this->themeFileImporter->getConcatenableScriptPath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
425
        }
426
427
        $concatenatedScriptsEvent = new ThemeCompilerConcatenatedScriptsEvent($concatenatedScripts, $salesChannelId);
428
        $this->eventDispatcher->dispatch($concatenatedScriptsEvent);
429
430
        return $concatenatedScriptsEvent->getConcatenatedScripts();
431
    }
432
433
    private function writeCompiledFiles(
434
        string $themePrefix,
435
        string $themeId,
436
        string $compiled,
437
        string $concatenatedScripts,
438
        bool $withAssets,
439
        StorefrontPluginConfiguration $themeConfig,
440
        StorefrontPluginConfigurationCollection $configurationCollection
441
    ): void {
442
        $path = 'theme' . \DIRECTORY_SEPARATOR . $themePrefix;
443
444
        if ($this->filesystem->has($path)) {
445
            $this->filesystem->deleteDirectory($path);
446
        }
447
448
        $cssFilePath = $path . \DIRECTORY_SEPARATOR . 'css' . \DIRECTORY_SEPARATOR . 'all.css';
449
        $this->filesystem->write($cssFilePath, $compiled);
450
451
        $scriptFilepath = $path . \DIRECTORY_SEPARATOR . 'js' . \DIRECTORY_SEPARATOR . 'all.js';
452
        $this->filesystem->write($scriptFilepath, $concatenatedScripts);
453
454
        // assets
455
        if ($withAssets) {
456
            $assetPath = 'theme' . \DIRECTORY_SEPARATOR . $themeId;
457
            if ($this->filesystem->has($assetPath)) {
458
                $this->filesystem->deleteDirectory($assetPath);
459
            }
460
461
            $this->copyAssets($themeConfig, $configurationCollection, $assetPath);
462
        }
463
    }
464
}
465