Passed
Push — trunk ( 7ae588...5f8f01 )
by Christian
13:45 queued 12s
created

ThemeCompiler::compileTheme()   B

Complexity

Conditions 8
Paths 9

Size

Total Lines 92
Code Lines 53

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

401
            $concatenatedStyles .= $this->themeFileImporter->getConcatenableStylePath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
402
        }
403
        $concatenatedStylesEvent = new ThemeCompilerConcatenatedStylesEvent($concatenatedStyles, $salesChannelId);
404
        $this->eventDispatcher->dispatch($concatenatedStylesEvent);
405
406
        return $concatenatedStylesEvent->getConcatenatedStyles();
407
    }
408
409
    private function getConcatenatedScripts(
410
        FileCollection $scriptFiles,
411
        StorefrontPluginConfiguration $themeConfig,
412
        string $salesChannelId
413
    ): string {
414
        $concatenatedScripts = '';
415
        foreach ($scriptFiles as $file) {
416
            $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

416
            $concatenatedScripts .= $this->themeFileImporter->getConcatenableScriptPath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
417
        }
418
419
        $concatenatedScriptsEvent = new ThemeCompilerConcatenatedScriptsEvent($concatenatedScripts, $salesChannelId);
420
        $this->eventDispatcher->dispatch($concatenatedScriptsEvent);
421
422
        return $concatenatedScriptsEvent->getConcatenatedScripts();
423
    }
424
425
    private function writeCompiledFiles(
426
        string $themePrefix,
427
        string $compiled,
428
        string $concatenatedScripts,
429
        bool $withAssets,
430
        StorefrontPluginConfiguration $themeConfig,
431
        StorefrontPluginConfigurationCollection $configurationCollection
432
    ): void {
433
        $path = 'theme' . \DIRECTORY_SEPARATOR . $themePrefix;
434
435
        if ($this->filesystem->has($path)) {
436
            $this->filesystem->deleteDirectory($path);
437
        }
438
439
        $cssFilePath = $path . \DIRECTORY_SEPARATOR . 'css' . \DIRECTORY_SEPARATOR . 'all.css';
440
        $this->filesystem->write($cssFilePath, $compiled);
441
442
        $scriptFilepath = $path . \DIRECTORY_SEPARATOR . 'js' . \DIRECTORY_SEPARATOR . 'all.js';
443
        $this->filesystem->write($scriptFilepath, $concatenatedScripts);
444
445
        // assets
446
        if ($withAssets) {
447
            $this->copyAssets($themeConfig, $configurationCollection, $path);
448
        }
449
    }
450
}
451