Passed
Push — trunk ( 3fca56...86677e )
by Christian
11:24 queued 12s
created

ThemeCompiler::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 15
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 0
nc 1
nop 13
dl 0
loc 15
rs 10
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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
            $themeId,
93
            $context
94
        );
95
96
        try {
97
            $concatenatedScripts = $this->getConcatenatedScripts($resolvedFiles[ThemeFileResolver::SCRIPT_FILES], $themeConfig, $salesChannelId);
98
        } catch (\Throwable $e) {
99
            throw new ThemeCompileException(
100
                $themeConfig->getName() ?? '',
101
                'Error while trying to concatenate Scripts: ' . $e->getMessage(),
102
                $e
103
            );
104
        }
105
106
        $newThemeHash = Uuid::randomHex();
107
        $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

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

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

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

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

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