Passed
Push — trunk ( dd8a19...7fc211 )
by Christian
14:51 queued 18s
created

ThemeCompiler::compileTheme()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 59
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 29
nc 4
nop 6
dl 0
loc 59
rs 9.456
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
/**
29
 * @package storefront
30
 */
31
class ThemeCompiler implements ThemeCompilerInterface
32
{
33
    /**
34
     * @internal
35
     *
36
     * @param Package[] $packages
37
     */
38
    public function __construct(
39
        private readonly FilesystemOperator $filesystem,
40
        private readonly FilesystemOperator $tempFilesystem,
41
        private readonly ThemeFileResolver $themeFileResolver,
42
        private readonly bool $debug,
43
        private readonly EventDispatcherInterface $eventDispatcher,
44
        private readonly ThemeFileImporterInterface $themeFileImporter,
45
        private readonly iterable $packages,
46
        private readonly CacheInvalidator $logger,
47
        private readonly AbstractThemePathBuilder $themePathBuilder,
48
        private readonly string $projectDir,
49
        private readonly AbstractScssCompiler $scssCompiler,
50
        private readonly MessageBusInterface $messageBus
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
            $context
78
        );
79
80
        $concatenatedScripts = $this->getConcatenatedScripts($resolvedFiles[ThemeFileResolver::SCRIPT_FILES], $themeConfig, $salesChannelId);
81
82
        $newThemeHash = Uuid::randomHex();
83
        $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

83
        $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...
84
        $oldThemePrefix = $this->themePathBuilder->assemblePath($salesChannelId, $themeId);
85
86
        try {
87
            $this->writeCompiledFiles($themePrefix, $compiled, $concatenatedScripts, $withAssets, $themeConfig, $configurationCollection);
88
        } catch (\Throwable $e) {
89
            // delete folder in case of error and rethrow exception
90
            if ($themePrefix !== $oldThemePrefix) {
91
                $this->filesystem->deleteDirectory($themePrefix);
92
            }
93
94
            throw $e;
95
        }
96
97
        $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

97
        /** @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...
98
99
        if ($themePrefix !== $oldThemePrefix) {
100
            // only delete the old directory if the `themePathBuilder` actually returned a new path and supports seeding
101
            // also delete with a delay of one our, so that the old theme is still available for a while in case some CDN delivers stale content
102
            $this->messageBus->dispatch(
103
                new Envelope(
104
                    new DeleteThemeFilesMessage($oldThemePrefix, $salesChannelId, $themeId),
105
                    // one hour in milliseconds
106
                    [new DelayStamp(3600 * 1000)]
107
                )
108
            );
109
        }
110
111
        // Reset cache buster state for improving performance in getMetadata
112
        $this->logger->invalidate(['theme-metaData'], true);
113
    }
114
115
    /**
116
     * @param array<string, string> $resolveMappings
117
     */
118
    public function getResolveImportPathsCallback(array $resolveMappings): \Closure
119
    {
120
        return function ($originalPath) use ($resolveMappings) {
121
            foreach ($resolveMappings as $resolve => $resolvePath) {
122
                $resolve = '~' . $resolve;
123
                if (mb_strpos($originalPath, $resolve) === 0) {
124
                    $dirname = $resolvePath . \dirname(mb_substr($originalPath, mb_strlen($resolve)));
125
126
                    $filename = basename($originalPath);
127
                    $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

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

368
            $concatenatedStyles .= $this->themeFileImporter->getConcatenableStylePath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
369
        }
370
        $concatenatedStylesEvent = new ThemeCompilerConcatenatedStylesEvent($concatenatedStyles, $salesChannelId);
371
        $this->eventDispatcher->dispatch($concatenatedStylesEvent);
372
373
        return $concatenatedStylesEvent->getConcatenatedStyles();
374
    }
375
376
    private function getConcatenatedScripts(
377
        FileCollection $scriptFiles,
378
        StorefrontPluginConfiguration $themeConfig,
379
        string $salesChannelId
380
    ): string {
381
        $concatenatedScripts = '';
382
        foreach ($scriptFiles as $file) {
383
            $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

383
            $concatenatedScripts .= $this->themeFileImporter->getConcatenableScriptPath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
384
        }
385
386
        $concatenatedScriptsEvent = new ThemeCompilerConcatenatedScriptsEvent($concatenatedScripts, $salesChannelId);
387
        $this->eventDispatcher->dispatch($concatenatedScriptsEvent);
388
389
        return $concatenatedScriptsEvent->getConcatenatedScripts();
390
    }
391
392
    private function writeCompiledFiles(
393
        string $themePrefix,
394
        string $compiled,
395
        string $concatenatedScripts,
396
        bool $withAssets,
397
        StorefrontPluginConfiguration $themeConfig,
398
        StorefrontPluginConfigurationCollection $configurationCollection
399
    ): void {
400
        $path = 'theme' . \DIRECTORY_SEPARATOR . $themePrefix;
401
402
        if ($this->filesystem->has($path)) {
403
            $this->filesystem->deleteDirectory($path);
404
        }
405
406
        $cssFilePath = $path . \DIRECTORY_SEPARATOR . 'css' . \DIRECTORY_SEPARATOR . 'all.css';
407
        $this->filesystem->write($cssFilePath, $compiled);
408
409
        $scriptFilepath = $path . \DIRECTORY_SEPARATOR . 'js' . \DIRECTORY_SEPARATOR . 'all.js';
410
        $this->filesystem->write($scriptFilepath, $concatenatedScripts);
411
412
        // assets
413
        if ($withAssets) {
414
            $this->copyAssets($themeConfig, $configurationCollection, $path);
415
        }
416
    }
417
}
418